tweak
This commit is contained in:
parent
5d09ad30d7
commit
ff89ec62fd
4 changed files with 112 additions and 75 deletions
20
config.js
20
config.js
|
|
@ -32,7 +32,13 @@ pressurePenaltyBase: 0.2,
|
|||
pressureReproductionPenalty: 0.16
|
||||
}),
|
||||
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({
|
||||
graphThrottleMs: 1400,
|
||||
|
|
@ -47,16 +53,14 @@ cityInnovation: 0.00045
|
|||
}),
|
||||
polity: Object.freeze({
|
||||
minimumPerCapitaFood: 0.06,
|
||||
logisticsDistance: 34
|
||||
logisticsDistance: 34,
|
||||
charismaMin: 0.5,
|
||||
charismaMax: 3.0,
|
||||
charismaAverage: 1.0
|
||||
}),
|
||||
polityAccess: Object.freeze({
|
||||
enabled: true,
|
||||
maxSearchDepth: 8,
|
||||
indirectPenalty: 0.012,
|
||||
perHopPenalty: 0.006,
|
||||
noAccessPenalty: 0.045,
|
||||
lowLoyaltyTransitPenalty: 0.012,
|
||||
foreignTransitPenalty: 0.008
|
||||
noAccessPenalty: 0.17
|
||||
}),
|
||||
culture: Object.freeze({
|
||||
spreadRadius: 3,
|
||||
|
|
|
|||
132
engine.js
132
engine.js
|
|
@ -1488,6 +1488,8 @@ agriculturalRadius: 2,
|
|||
tradeLinks: new Set(),
|
||||
activeVisitors: 0,
|
||||
age: 0,
|
||||
ageWear: 0,
|
||||
peakPopulation: seedPopulation,
|
||||
strength: 3,
|
||||
sedentaryCulture: seedSedentary,
|
||||
knowledge: { farming: 0, metallurgy: 0 },
|
||||
|
|
@ -1499,6 +1501,21 @@ tradeValue: 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() {
|
||||
if (!this.cities.length) return;
|
||||
const absorbed = [];
|
||||
|
|
@ -1535,6 +1552,7 @@ return whole + (this.rng.next() < value - whole ? 1 : 0);
|
|||
processCityEconomies() {
|
||||
const w = this.world;
|
||||
for (const city of this.cities) {
|
||||
this.updateCityAging(city);
|
||||
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.knowledge ??= { farming: 0, metallurgy: 0 };
|
||||
|
|
@ -1579,13 +1597,22 @@ city.tradeReach = trade.reach;
|
|||
city.tradeValue = 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 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.storedResources -= births * 0.55;
|
||||
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) {
|
||||
const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
|
||||
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);
|
||||
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) {
|
||||
const cfg = SimConfig.polityAccess;
|
||||
if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0;
|
||||
const access = this.tradeAccessToCenter(city, center, polity.id);
|
||||
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;
|
||||
return clamp(cfg.noAccessPenalty, 0, 0.24);
|
||||
}
|
||||
}
|
||||
return clamp(penalty, 0, 0.07);
|
||||
rollLeaderCharisma() {
|
||||
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) {
|
||||
let distance = this.distanceBetweenCities(cityA, cityB);
|
||||
|
|
@ -1799,7 +1790,7 @@ color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
|
|||
founded: this.year,
|
||||
legitimacy: this.rng.range(0.68, 0.94),
|
||||
cohesion: this.rng.range(0.58, 0.9),
|
||||
charisma: this.rng.range(0.5, 1.5),
|
||||
charisma: this.rollLeaderCharisma(),
|
||||
leaderStarted: this.year,
|
||||
leaderTenureYears: this.rng.range(24, 68),
|
||||
crisis: 0,
|
||||
|
|
@ -1905,17 +1896,19 @@ while (this.graphEvents.length > 160) this.graphEvents.shift();
|
|||
}
|
||||
selectNewLeader(polity, forced = false) {
|
||||
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 centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
|
||||
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 institutionalPull = 0.82 + institutionalBias * 0.36;
|
||||
const nextCharisma = clamp(
|
||||
previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
|
||||
0.5,
|
||||
1.5
|
||||
charismaMin,
|
||||
charismaMax
|
||||
);
|
||||
polity.charisma = nextCharisma;
|
||||
polity.leaderStarted = this.year;
|
||||
|
|
@ -1927,7 +1920,7 @@ if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
|
|||
}
|
||||
updatePolityLeaders() {
|
||||
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.leaderTenureYears ??= this.rng.range(24, 68);
|
||||
const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR;
|
||||
|
|
@ -2194,7 +2187,7 @@ tech * 24 -
|
|||
instability * 12 -
|
||||
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) {
|
||||
const power = this.polityPower(polity);
|
||||
|
|
@ -2679,6 +2672,34 @@ city.receivedAid = true;
|
|||
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() {
|
||||
|
|
@ -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 += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
|
||||
const directCenterTrade = this.hasDirectTradeConnection(city, center);
|
||||
if (directCenterTrade) delta += 0.025;
|
||||
else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
|
||||
if (!directCenterTrade) delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
|
||||
if (city.receivedAid) delta += 0.04;
|
||||
delta -= 0.004;
|
||||
if (city.storedResources < city.population * 0.05) delta -= 0.035;
|
||||
|
|
|
|||
34
render.js
34
render.js
|
|
@ -290,15 +290,16 @@ els.tooltip.innerHTML = `
|
|||
])}
|
||||
${tooltipSection("City & State", [
|
||||
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("Supply stress", (city?.supplyStress || 0).toFixed(2), 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("City majority", `E${cityEthnicity}`, cityEthnicity),
|
||||
tooltipRow("City majority", cityEthnicity != null ? `E${cityEthnicity}` : "", cityEthnicity != null),
|
||||
city ? cityEthnicityPie(city) : "",
|
||||
tooltipRow("State", polity ? `#${polity.id}` : "Independent", 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("Treasury", polity?.treasury.toFixed(1), 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`;
|
||||
}
|
||||
function cityEthnicityPie(city) {
|
||||
if (!city?.ethnicityComposition?.size) return "";
|
||||
const entries = [...city.ethnicityComposition]
|
||||
.filter(([, count]) => count > 0)
|
||||
if (!city) return "";
|
||||
let entries = [...(city.ethnicityComposition || new Map())]
|
||||
.map(([id, count]) => [id, Number(count)])
|
||||
.filter(([, count]) => Number.isFinite(count) && count > 0)
|
||||
.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);
|
||||
if (total <= 0) return "";
|
||||
const top = entries.slice(0, 5);
|
||||
const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0);
|
||||
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;
|
||||
const gradient = slices.map(([id, count]) => {
|
||||
const start = cursor / total * 100;
|
||||
cursor += count;
|
||||
const end = cursor / total * 100;
|
||||
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
||||
const color = ethnicity?.color || [122, 130, 126];
|
||||
const color = sliceColor(id);
|
||||
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
|
||||
}).join(", ");
|
||||
const labels = slices.map(([id, count]) => {
|
||||
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
||||
const color = ethnicity?.color || [122, 130, 126];
|
||||
const label = id ? `E${id}` : "Other";
|
||||
const color = sliceColor(id);
|
||||
const label = id !== 0 ? `E${id}` : "Other";
|
||||
return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`;
|
||||
}).join("");
|
||||
const background = slices.length === 1
|
||||
? `rgb(${sliceColor(slices[0][0]).join(",")})`
|
||||
: `conic-gradient(${gradient})`;
|
||||
return `
|
||||
<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>
|
||||
`;
|
||||
|
|
|
|||
1
utils.js
1
utils.js
|
|
@ -77,6 +77,7 @@ function dominantComposition(composition) {
|
|||
let bestId = null;
|
||||
let bestCount = 0;
|
||||
for (const [id, count] of composition) {
|
||||
if (!Number.isFinite(count) || count <= 0) continue;
|
||||
if (count > bestCount) {
|
||||
bestId = id;
|
||||
bestCount = count;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue