brush up
This commit is contained in:
parent
8ff52b3fbd
commit
97659fdd2a
2 changed files with 416 additions and 64 deletions
|
|
@ -52,7 +52,6 @@
|
|||
<option value="terrain">Terrain</option>
|
||||
<option value="resources">Resources</option>
|
||||
<option value="ethnicity">Ethnicity</option>
|
||||
<option value="pheromone">Trade trails</option>
|
||||
<option value="pressure">Population pressure</option>
|
||||
<option value="polities">Polities</option>
|
||||
<option value="technology">Technology</option>
|
||||
|
|
@ -92,7 +91,7 @@
|
|||
<section class="panel history-panel">
|
||||
<div class="history-header">
|
||||
<h2>State lifespans</h2>
|
||||
<span id="historyRange">—</span>
|
||||
<span id="historyRange">-</span>
|
||||
</div>
|
||||
<canvas id="stateGraph" width="300" height="720" aria-label="State lifespan graph"></canvas>
|
||||
</section>
|
||||
|
|
|
|||
477
script.js
477
script.js
|
|
@ -19,6 +19,47 @@ const terrainInfo = [
|
|||
];
|
||||
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
const SAVE_KEY = "civil-emergence-save";
|
||||
const SAVE_VERSION = 3;
|
||||
const MAX_SAVE_BYTES = 4_500_000;
|
||||
|
||||
const SimConfig = Object.freeze({
|
||||
population: Object.freeze({
|
||||
maxAgentsFloor: 8000,
|
||||
maxAgentsScale: 1.15,
|
||||
maxOffspringPerStep: 360,
|
||||
carryingCapacityBase: 2.4,
|
||||
carryingCapacityFertility: 7.8,
|
||||
carryingCapacityMineral: 1.8,
|
||||
carryingCapacityFarmland: 5.5,
|
||||
pressurePenaltyBase: 0.2,
|
||||
pressureReproductionPenalty: 0.16
|
||||
}),
|
||||
render: Object.freeze({
|
||||
graphThrottleMs: 700,
|
||||
statsThrottleMs: 350
|
||||
}),
|
||||
save: Object.freeze({
|
||||
key: SAVE_KEY,
|
||||
version: SAVE_VERSION,
|
||||
maxBytes: MAX_SAVE_BYTES
|
||||
}),
|
||||
technology: Object.freeze({
|
||||
cityDiffusion: 0.0022,
|
||||
tradeDiffusion: 0.0032,
|
||||
cityInnovation: 0.00045
|
||||
}),
|
||||
polity: Object.freeze({
|
||||
minimumPerCapitaFood: 0.06,
|
||||
logisticsDistance: 34
|
||||
}),
|
||||
culture: Object.freeze({
|
||||
spreadRadius: 3,
|
||||
cityWeight: 0.035,
|
||||
routeWeight: 1.35,
|
||||
minimumInfluence: 0.18
|
||||
})
|
||||
});
|
||||
|
||||
function years(value) {
|
||||
return value * MONTHS_PER_YEAR;
|
||||
|
|
@ -62,6 +103,8 @@ let frame = 0;
|
|||
let lastStatsAt = 0;
|
||||
let lastGraphRenderAt = 0;
|
||||
let hoverState = null;
|
||||
let renderImage = null;
|
||||
let renderImageSize = 0;
|
||||
|
||||
class Rng {
|
||||
constructor(seed) {
|
||||
|
|
@ -83,7 +126,7 @@ class Rng {
|
|||
}
|
||||
|
||||
class World {
|
||||
constructor(size, rng) {
|
||||
constructor(size, rng, generate = true) {
|
||||
this.size = size;
|
||||
this.count = size * size;
|
||||
this.rng = rng;
|
||||
|
|
@ -101,8 +144,15 @@ class World {
|
|||
this.cityPull = new Float32Array(this.count);
|
||||
this.city = new Int32Array(this.count);
|
||||
this.pressure = new Float32Array(this.count);
|
||||
this.dominantEthnicity = new Int32Array(this.count);
|
||||
this.cultureDiversity = new Float32Array(this.count);
|
||||
this.city.fill(-1);
|
||||
this.generate();
|
||||
this.dominantEthnicity.fill(-1);
|
||||
if (generate) this.generate();
|
||||
}
|
||||
|
||||
static blank(size, rng) {
|
||||
return new World(size, rng, false);
|
||||
}
|
||||
|
||||
idx(x, y) {
|
||||
|
|
@ -372,9 +422,9 @@ class World {
|
|||
}
|
||||
|
||||
class Simulation {
|
||||
constructor(size, initialAgents) {
|
||||
constructor(size, initialAgents, options = {}) {
|
||||
this.rng = new Rng(Date.now());
|
||||
this.world = new World(size, this.rng);
|
||||
this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng);
|
||||
this.agents = [];
|
||||
this.ethnicities = new Map();
|
||||
this.cities = [];
|
||||
|
|
@ -387,12 +437,17 @@ class Simulation {
|
|||
this.nextPolity = 1;
|
||||
this.year = 0;
|
||||
this.deaths = 0;
|
||||
this.maxAgents = Math.max(initialAgents * 1.25, 30000);
|
||||
this.maxAgents = Math.max(
|
||||
Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
|
||||
SimConfig.population.maxAgentsFloor
|
||||
);
|
||||
this.tileEthnicities = new Map();
|
||||
this.tileAgents = new Map();
|
||||
this.spawnInitialAgents(initialAgents);
|
||||
this.rebuildOccupancy();
|
||||
this.updateEthnicStats();
|
||||
if (!options.skipSpawn) {
|
||||
this.spawnInitialAgents(initialAgents);
|
||||
this.rebuildOccupancy();
|
||||
this.updateEthnicStats();
|
||||
}
|
||||
}
|
||||
|
||||
spawnInitialAgents(count) {
|
||||
|
|
@ -551,16 +606,16 @@ class Simulation {
|
|||
}
|
||||
|
||||
this.agents = this.agents.filter(a => a.alive);
|
||||
if (this.agents.length + offspring.length < this.maxAgents) {
|
||||
this.agents.push(...offspring);
|
||||
} else {
|
||||
this.agents.push(...offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)));
|
||||
}
|
||||
const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents
|
||||
? offspring
|
||||
: offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length));
|
||||
this.agents.push(...acceptedOffspring);
|
||||
for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
|
||||
|
||||
this.updateWorldFields();
|
||||
this.rebuildOccupancy();
|
||||
if (this.year % years(1) === 0) {
|
||||
this.updateCities();
|
||||
this.updateRegionalCultures();
|
||||
}
|
||||
if (this.year % years(5) === 0) {
|
||||
this.updateTradeRoutes();
|
||||
|
|
@ -596,6 +651,137 @@ class Simulation {
|
|||
}
|
||||
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
|
||||
}
|
||||
for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
|
||||
}
|
||||
|
||||
updateCultureTile(tile) {
|
||||
const counts = this.tileEthnicities.get(tile);
|
||||
if (!counts || !counts.size) {
|
||||
this.world.cultureDiversity[tile] *= 0.98;
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
let dominant = -1;
|
||||
let dominantCount = 0;
|
||||
for (const [id, count] of counts) {
|
||||
total += count;
|
||||
if (count > dominantCount) {
|
||||
dominant = id;
|
||||
dominantCount = count;
|
||||
}
|
||||
}
|
||||
this.world.dominantEthnicity[tile] = dominant;
|
||||
this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
|
||||
}
|
||||
|
||||
updateRegionalCultures() {
|
||||
const w = this.world;
|
||||
const nextDominant = new Int32Array(w.dominantEthnicity);
|
||||
const nextDiversity = new Float32Array(w.cultureDiversity);
|
||||
const radius = SimConfig.culture.spreadRadius;
|
||||
const cityById = new Map(this.cities.map(city => [city.id, city]));
|
||||
|
||||
for (let y = 0; y < w.size; y++) {
|
||||
for (let x = 0; x < w.size; x++) {
|
||||
const tile = w.idx(x, y);
|
||||
if (w.terrain[tile] === Terrain.WATER) continue;
|
||||
const influence = new Map();
|
||||
let total = 0;
|
||||
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const distance = Math.abs(dx) + Math.abs(dy);
|
||||
if (distance > radius) continue;
|
||||
const tx = x + dx;
|
||||
const ty = y + dy;
|
||||
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
||||
const source = w.idx(tx, ty);
|
||||
const id = w.dominantEthnicity[source];
|
||||
if (id < 0) continue;
|
||||
const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source]));
|
||||
const cityWeight = w.city[source] >= 0
|
||||
? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight
|
||||
: 0;
|
||||
const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1;
|
||||
const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance);
|
||||
if (weight <= 0) continue;
|
||||
influence.set(id, (influence.get(id) || 0) + weight);
|
||||
total += weight;
|
||||
}
|
||||
}
|
||||
|
||||
let bestId = nextDominant[tile];
|
||||
let bestWeight = 0;
|
||||
for (const [id, weight] of influence) {
|
||||
if (weight > bestWeight) {
|
||||
bestId = id;
|
||||
bestWeight = weight;
|
||||
}
|
||||
}
|
||||
if (bestWeight >= SimConfig.culture.minimumInfluence) {
|
||||
nextDominant[tile] = bestId;
|
||||
nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.dominantEthnicity.set(nextDominant);
|
||||
w.cultureDiversity.set(nextDiversity);
|
||||
}
|
||||
|
||||
addAgentToOccupancy(a) {
|
||||
if (!a.alive) return;
|
||||
const w = this.world;
|
||||
const i = w.idx(a.x, a.y);
|
||||
w.pressure[i]++;
|
||||
let agents = this.tileAgents.get(i);
|
||||
if (!agents) {
|
||||
agents = [];
|
||||
this.tileAgents.set(i, agents);
|
||||
}
|
||||
agents.push(a);
|
||||
let counts = this.tileEthnicities.get(i);
|
||||
if (!counts) {
|
||||
counts = new Map();
|
||||
this.tileEthnicities.set(i, counts);
|
||||
}
|
||||
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
|
||||
this.updateCultureTile(i);
|
||||
}
|
||||
|
||||
removeAgentFromOccupancy(a) {
|
||||
const w = this.world;
|
||||
const i = w.idx(a.x, a.y);
|
||||
w.pressure[i] = Math.max(0, w.pressure[i] - 1);
|
||||
const agents = this.tileAgents.get(i);
|
||||
if (agents) {
|
||||
const index = agents.indexOf(a);
|
||||
if (index >= 0) agents.splice(index, 1);
|
||||
if (!agents.length) this.tileAgents.delete(i);
|
||||
}
|
||||
const counts = this.tileEthnicities.get(i);
|
||||
if (counts) {
|
||||
const next = (counts.get(a.ethnicity) || 0) - 1;
|
||||
if (next > 0) counts.set(a.ethnicity, next);
|
||||
else counts.delete(a.ethnicity);
|
||||
if (!counts.size) this.tileEthnicities.delete(i);
|
||||
}
|
||||
this.updateCultureTile(i);
|
||||
}
|
||||
|
||||
changeAgentEthnicity(a, nextEthnicity) {
|
||||
if (a.ethnicity === nextEthnicity) return;
|
||||
const w = this.world;
|
||||
const i = w.idx(a.x, a.y);
|
||||
const counts = this.tileEthnicities.get(i);
|
||||
if (counts) {
|
||||
const prev = (counts.get(a.ethnicity) || 0) - 1;
|
||||
if (prev > 0) counts.set(a.ethnicity, prev);
|
||||
else counts.delete(a.ethnicity);
|
||||
counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1);
|
||||
}
|
||||
a.ethnicity = nextEthnicity;
|
||||
this.updateCultureTile(i);
|
||||
}
|
||||
|
||||
moveAgent(a) {
|
||||
|
|
@ -610,7 +796,9 @@ class Simulation {
|
|||
const ethnocentrism = getEthnocentrism(a.traits);
|
||||
const ethnicClimate = this.ethnicities.get(a.ethnicity);
|
||||
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
|
||||
if (localPressure < 7 && this.rng.next() < sedentary * 0.58) {
|
||||
const localCapacity = this.carryingCapacityAt(current);
|
||||
const localOverCapacity = Math.max(0, localPressure - localCapacity);
|
||||
if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) {
|
||||
a.settled++;
|
||||
a.movedThisStep = false;
|
||||
return;
|
||||
|
|
@ -631,12 +819,16 @@ class Simulation {
|
|||
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
|
||||
const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
|
||||
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
|
||||
const crowdPenalty = Math.max(0, w.pressure[i] - 3) * (0.35 + a.traits.mobility);
|
||||
const destinationCapacity = this.carryingCapacityAt(i);
|
||||
const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity);
|
||||
const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1);
|
||||
const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85);
|
||||
const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
|
||||
const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
|
||||
const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
|
||||
const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
|
||||
const pressurePush = localPressure > 8 ? a.traits.mobility * (1.65 - sedentary * 0.7) : 0;
|
||||
const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0;
|
||||
const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45);
|
||||
|
||||
const score =
|
||||
resourceScore * a.traits.resourceAttraction +
|
||||
|
|
@ -644,6 +836,7 @@ class Simulation {
|
|||
ethnicDensity.same * ethnocentrism +
|
||||
cityPull * sedentary +
|
||||
routePull +
|
||||
capacityPull +
|
||||
inertia -
|
||||
terrainPenalty -
|
||||
crowdPenalty +
|
||||
|
|
@ -697,12 +890,23 @@ class Simulation {
|
|||
return 0.0;
|
||||
}
|
||||
|
||||
carryingCapacityAt(tile) {
|
||||
const w = this.world;
|
||||
if (w.terrain[tile] === Terrain.WATER) return 1.2;
|
||||
return SimConfig.population.carryingCapacityBase +
|
||||
w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
|
||||
w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
|
||||
w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
|
||||
(w.city[tile] >= 0 ? 3.5 : 0);
|
||||
}
|
||||
|
||||
updateAgentTechnology(agent, payMaintenance = false) {
|
||||
if (!agent.alive) return;
|
||||
this.ensureAgentTech(agent);
|
||||
this.updateFarmingWork(agent);
|
||||
this.tryInventTechnology(agent);
|
||||
this.spreadTechnology(agent);
|
||||
this.learnTechnologyFromCity(agent);
|
||||
this.improveTechnologyFromDensity(agent);
|
||||
if (payMaintenance) this.payTechnologyCostOrForget(agent);
|
||||
}
|
||||
|
|
@ -759,14 +963,15 @@ class Simulation {
|
|||
|
||||
grantTechnologyAround(agent, techName, amount) {
|
||||
this.ensureAgentTech(agent);
|
||||
for (const other of this.localAgentsNear(agent.x, agent.y, 2)) {
|
||||
if (!other.alive) continue;
|
||||
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
|
||||
if (!other.alive) return true;
|
||||
this.ensureAgentTech(other);
|
||||
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
|
||||
if (distance > 2) continue;
|
||||
if (distance > 2) return true;
|
||||
const gain = amount * (other === agent ? 1 : 0.55);
|
||||
other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
payTechnologyCostOrForget(agent) {
|
||||
|
|
@ -799,10 +1004,10 @@ class Simulation {
|
|||
const ethnocentrism = agent.traits.ethnocentrism || 0;
|
||||
let checked = 0;
|
||||
|
||||
for (const other of this.localAgentsNear(agent.x, agent.y, 1)) {
|
||||
if (other === agent || !other.alive) continue;
|
||||
if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) continue;
|
||||
if (++checked > 12) break;
|
||||
this.forEachLocalAgentNear(agent.x, agent.y, 1, other => {
|
||||
if (other === agent || !other.alive) return true;
|
||||
if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true;
|
||||
if (++checked > 12) return false;
|
||||
this.ensureAgentTech(other);
|
||||
const sameEthnicity = agent.ethnicity === other.ethnicity;
|
||||
let chance =
|
||||
|
|
@ -817,7 +1022,8 @@ class Simulation {
|
|||
|
||||
this.learnTechnologyFrom(agent, other, "farming", chance);
|
||||
this.learnTechnologyFrom(agent, other, "metallurgy", chance);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
learnTechnologyFrom(agent, other, techName, chance) {
|
||||
|
|
@ -828,17 +1034,29 @@ class Simulation {
|
|||
}
|
||||
}
|
||||
|
||||
learnTechnologyFromCity(agent) {
|
||||
const tile = this.world.idx(agent.x, agent.y);
|
||||
const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null;
|
||||
if (!city?.knowledge) return;
|
||||
const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0;
|
||||
const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006);
|
||||
if (this.rng.next() < chance) {
|
||||
agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1);
|
||||
agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
improveTechnologyFromDensity(agent) {
|
||||
let farmingCount = 0;
|
||||
let metallurgyCount = 0;
|
||||
let farmingSum = 0;
|
||||
let metallurgySum = 0;
|
||||
|
||||
for (const other of this.localAgentsNear(agent.x, agent.y, 2)) {
|
||||
if (!other.alive) continue;
|
||||
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
|
||||
if (!other.alive) return true;
|
||||
this.ensureAgentTech(other);
|
||||
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
|
||||
if (distance > 2) continue;
|
||||
if (distance > 2) return true;
|
||||
const farming = other.tech.farming || 0;
|
||||
const metallurgy = other.tech.metallurgy || 0;
|
||||
if (farming > 0.1) {
|
||||
|
|
@ -849,7 +1067,8 @@ class Simulation {
|
|||
metallurgyCount++;
|
||||
metallurgySum += metallurgy;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (farmingCount >= 2) {
|
||||
const avgFarming = farmingSum / farmingCount;
|
||||
|
|
@ -862,19 +1081,20 @@ class Simulation {
|
|||
}
|
||||
}
|
||||
|
||||
localAgentsNear(x, y, radius) {
|
||||
forEachLocalAgentNear(x, y, radius, callback) {
|
||||
const w = this.world;
|
||||
const found = [];
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const tx = x + dx;
|
||||
const ty = y + dy;
|
||||
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
|
||||
const agents = this.tileAgents.get(w.idx(tx, ty));
|
||||
if (agents) found.push(...agents);
|
||||
if (!agents) continue;
|
||||
for (const agent of agents) {
|
||||
if (callback(agent) === false) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
farmingGatherMultiplier(agent, tile) {
|
||||
|
|
@ -903,7 +1123,9 @@ class Simulation {
|
|||
const drylandAdapted = this.isDrylandAdapted(ethnicity);
|
||||
const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
|
||||
const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
|
||||
const pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18);
|
||||
const carryingCapacity = this.carryingCapacityAt(i);
|
||||
const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity);
|
||||
const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase);
|
||||
const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2);
|
||||
const gathered = Math.min(
|
||||
w.resource[i],
|
||||
|
|
@ -920,6 +1142,7 @@ class Simulation {
|
|||
a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
|
||||
|
||||
if (a.resources <= 0) {
|
||||
this.removeAgentFromOccupancy(a);
|
||||
a.alive = false;
|
||||
this.deaths++;
|
||||
return;
|
||||
|
|
@ -927,8 +1150,9 @@ class Simulation {
|
|||
|
||||
const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
|
||||
const mobilityPenalty = (1 - sedentary) * 0.45;
|
||||
const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty - settlementBonus, 0.82, 1.55);
|
||||
if (a.resources > reproductionThreshold && offspring.length < 700) {
|
||||
const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty;
|
||||
const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35);
|
||||
if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) {
|
||||
const childShare = 0.36 + sedentary * 0.08;
|
||||
const childResources = a.resources * childShare;
|
||||
a.resources -= childResources;
|
||||
|
|
@ -990,7 +1214,7 @@ class Simulation {
|
|||
const pressure = dominant.count / Math.max(1, dominant.total);
|
||||
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
|
||||
if (this.rng.next() < chance * 0.14) {
|
||||
a.ethnicity = dominant.id;
|
||||
this.changeAgentEthnicity(a, dominant.id);
|
||||
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
|
||||
a.foreignContact = 0;
|
||||
}
|
||||
|
|
@ -1037,7 +1261,6 @@ class Simulation {
|
|||
for (let i = 0; i < w.count; i++) {
|
||||
w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 1.15));
|
||||
w.pheromone[i] *= 0.996;
|
||||
w.pressure[i] *= 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1058,12 +1281,16 @@ class Simulation {
|
|||
const i = w.idx(cx, cy);
|
||||
let group = candidates.get(i);
|
||||
if (!group) {
|
||||
group = { count: 0, resources: 0, sedentary: 0, ethnicities: new Map() };
|
||||
group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() };
|
||||
candidates.set(i, group);
|
||||
}
|
||||
this.ensureAgentTech(a);
|
||||
group.count++;
|
||||
group.resources += Math.max(0, a.resources);
|
||||
group.sedentary += getSedentary(a.traits);
|
||||
group.farming += a.tech.farming || 0;
|
||||
group.metallurgy += a.tech.metallurgy || 0;
|
||||
group.techCount++;
|
||||
group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
|
||||
}
|
||||
|
||||
|
|
@ -1090,6 +1317,9 @@ class Simulation {
|
|||
city.activeVisitors += group.count;
|
||||
city.storedResources += group.resources * 0.08;
|
||||
city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
|
||||
city.knowledge ??= { farming: 0, metallurgy: 0 };
|
||||
city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount));
|
||||
city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount));
|
||||
const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
|
||||
for (const [id, count] of group.ethnicities) {
|
||||
const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
|
||||
|
|
@ -1100,6 +1330,7 @@ class Simulation {
|
|||
}
|
||||
|
||||
this.absorbUrbanPopulation();
|
||||
this.rebuildOccupancy();
|
||||
this.processCityEconomies();
|
||||
|
||||
this.cities = this.cities.filter(c => {
|
||||
|
|
@ -1157,6 +1388,8 @@ class Simulation {
|
|||
age: 0,
|
||||
strength: 3,
|
||||
sedentaryCulture: seedSedentary,
|
||||
knowledge: { farming: 0, metallurgy: 0 },
|
||||
supplyStress: 0,
|
||||
polityId: null,
|
||||
loyalty: 0.5,
|
||||
receivedAid: false
|
||||
|
|
@ -1200,6 +1433,7 @@ class Simulation {
|
|||
for (const city of this.cities) {
|
||||
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 };
|
||||
let harvested = 0;
|
||||
const radius = city.agriculturalRadius;
|
||||
|
||||
|
|
@ -1211,7 +1445,9 @@ class Simulation {
|
|||
const i = w.idx(x, y);
|
||||
if (w.terrain[i] === Terrain.WATER) continue;
|
||||
const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
|
||||
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 + w.mineral[i] * 0.045) * pull);
|
||||
const farmingYield = 1 + city.knowledge.farming * 0.85;
|
||||
const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
|
||||
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
|
||||
w.resource[i] -= extraction;
|
||||
w.farmland[i] = Math.max(w.farmland[i], pull);
|
||||
w.pheromone[i] += city.pheromoneOutput * pull * 0.09;
|
||||
|
|
@ -1221,8 +1457,12 @@ class Simulation {
|
|||
|
||||
city.storedResources += harvested;
|
||||
const supportRatio = city.activeVisitors / Math.max(1, city.population);
|
||||
const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09);
|
||||
const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
|
||||
const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09) + knowledgeMaintenance;
|
||||
city.storedResources -= upkeep;
|
||||
const foodPerCapita = city.storedResources / Math.max(1, city.population);
|
||||
city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8, 0, 1.8);
|
||||
this.innovateCityKnowledge(city, harvested);
|
||||
if (city.storedResources > city.population * 0.16 && city.population > 0) {
|
||||
const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12, 0, 0.8);
|
||||
const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012)));
|
||||
|
|
@ -1253,6 +1493,27 @@ class Simulation {
|
|||
}
|
||||
}
|
||||
|
||||
innovateCityKnowledge(city, harvested) {
|
||||
city.knowledge ??= { farming: 0, metallurgy: 0 };
|
||||
const scale = SimConfig.technology.cityInnovation;
|
||||
const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6);
|
||||
const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5);
|
||||
city.knowledge.farming = clamp(
|
||||
city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015,
|
||||
0,
|
||||
1
|
||||
);
|
||||
city.knowledge.metallurgy = clamp(
|
||||
city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35,
|
||||
0,
|
||||
1
|
||||
);
|
||||
if (city.supplyStress > 0.8) {
|
||||
city.knowledge.farming *= 0.998;
|
||||
city.knowledge.metallurgy *= 0.997;
|
||||
}
|
||||
}
|
||||
|
||||
spawnUrbanRefugees(city, count, ethnicity = null) {
|
||||
const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
|
||||
const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
|
||||
|
|
@ -1470,15 +1731,37 @@ class Simulation {
|
|||
if (!cities.length) return 1;
|
||||
|
||||
let poor = 0;
|
||||
let supplyStress = 0;
|
||||
for (const city of cities) {
|
||||
const perCapita = city.storedResources / Math.max(1, city.population);
|
||||
if (perCapita < 0.06) poor++;
|
||||
supplyStress += city.supplyStress || 0;
|
||||
}
|
||||
|
||||
const povertyRate = poor / cities.length;
|
||||
const avgSupplyStress = supplyStress / cities.length;
|
||||
const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
|
||||
const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
|
||||
return clamp(povertyRate * 0.55 + treasuryStress * 0.28, 0, 1.5);
|
||||
return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5);
|
||||
}
|
||||
|
||||
polityLogisticsStress(polity) {
|
||||
const cities = this.getPolityCities(polity);
|
||||
const center = this.getCityById(polity.centerCityId);
|
||||
if (!center || cities.length <= 1) return 0;
|
||||
let stress = 0;
|
||||
let count = 0;
|
||||
for (const city of cities) {
|
||||
if (city.id === center.id) continue;
|
||||
const distance = this.effectiveDistance(center, city);
|
||||
const food = city.storedResources / Math.max(1, city.population);
|
||||
const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012;
|
||||
const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5;
|
||||
const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1;
|
||||
stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief;
|
||||
count++;
|
||||
}
|
||||
return clamp(stress / Math.max(1, count), 0, 1.8);
|
||||
}
|
||||
|
||||
polityEthnicFragmentation(polity) {
|
||||
|
|
@ -1502,6 +1785,7 @@ class Simulation {
|
|||
const agePressure = this.polityAgePressure(polity);
|
||||
const overextension = this.polityOverextension(polity);
|
||||
const resourceStress = this.polityResourceStress(polity);
|
||||
const logisticsStress = this.polityLogisticsStress(polity);
|
||||
const fragmentation = this.polityEthnicFragmentation(polity);
|
||||
|
||||
const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
|
||||
|
|
@ -1510,6 +1794,7 @@ class Simulation {
|
|||
agePressure * 0.35 +
|
||||
overextension * 0.24 +
|
||||
resourceStress * 0.38 +
|
||||
logisticsStress * 0.30 +
|
||||
fragmentation * 0.22 +
|
||||
(polity.crisis || 0) * 0.42 -
|
||||
legitimacyBuffer -
|
||||
|
|
@ -1715,6 +2000,7 @@ class Simulation {
|
|||
const instability = this.polityInstability(polity);
|
||||
const agePressure = this.polityAgePressure(polity);
|
||||
const overextension = this.polityOverextension(polity);
|
||||
const logisticsStress = this.polityLogisticsStress(polity);
|
||||
for (const city of this.getPolityCities(polity)) {
|
||||
if (city.id === center.id) continue;
|
||||
const perCapita = city.storedResources / Math.max(1, city.population);
|
||||
|
|
@ -1727,9 +2013,11 @@ class Simulation {
|
|||
if (city.receivedAid) delta += 0.04;
|
||||
delta -= 0.004;
|
||||
if (city.storedResources < city.population * 0.05) delta -= 0.035;
|
||||
delta -= (city.supplyStress || 0) * 0.018;
|
||||
delta -= instability * 0.020;
|
||||
delta -= agePressure * 0.010;
|
||||
delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
|
||||
delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018;
|
||||
if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020;
|
||||
if ((polity.crisis || 0) > 0.6) delta -= 0.014;
|
||||
city.loyalty = clamp(city.loyalty + delta, 0, 1);
|
||||
|
|
@ -1896,6 +2184,24 @@ class Simulation {
|
|||
for (let i = 0; i < w.count; i++) {
|
||||
if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
|
||||
}
|
||||
this.diffuseCityKnowledgeThroughTrade();
|
||||
}
|
||||
|
||||
diffuseCityKnowledgeThroughTrade() {
|
||||
for (const link of this.tradeLinks) {
|
||||
const a = this.getCityById(link.from);
|
||||
const b = this.getCityById(link.to);
|
||||
if (!a || !b) continue;
|
||||
a.knowledge ??= { farming: 0, metallurgy: 0 };
|
||||
b.knowledge ??= { farming: 0, metallurgy: 0 };
|
||||
const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012);
|
||||
const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate;
|
||||
const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate;
|
||||
a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1);
|
||||
b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1);
|
||||
a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1);
|
||||
b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
findTerrainRoute(x1, y1, x2, y2) {
|
||||
|
|
@ -2118,7 +2424,7 @@ class Simulation {
|
|||
|
||||
toJSON() {
|
||||
return {
|
||||
version: 2,
|
||||
version: SimConfig.save.version,
|
||||
rngSeed: this.rng.seed,
|
||||
year: this.year,
|
||||
deaths: this.deaths,
|
||||
|
|
@ -2141,7 +2447,9 @@ class Simulation {
|
|||
farmland: packArray(this.world.farmland),
|
||||
cityPull: packArray(this.world.cityPull),
|
||||
city: packArray(this.world.city),
|
||||
pressure: packArray(this.world.pressure)
|
||||
pressure: packArray(this.world.pressure),
|
||||
dominantEthnicity: packArray(this.world.dominantEthnicity),
|
||||
cultureDiversity: packArray(this.world.cultureDiversity)
|
||||
},
|
||||
agents: this.agents,
|
||||
ethnicities: [...this.ethnicities.values()].map(e => ({
|
||||
|
|
@ -2175,7 +2483,8 @@ class Simulation {
|
|||
}
|
||||
|
||||
static fromJSON(state) {
|
||||
const sim = new Simulation(state.world.size, 0);
|
||||
if (!state?.world?.size) throw new Error("Invalid save payload");
|
||||
const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true });
|
||||
sim.rng.seed = state.rngSeed >>> 0;
|
||||
sim.year = state.year || 0;
|
||||
sim.deaths = state.deaths || 0;
|
||||
|
|
@ -2197,7 +2506,9 @@ class Simulation {
|
|||
sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
|
||||
sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
|
||||
sim.world.city.set(unpackArray(state.world.city, Int32Array));
|
||||
sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
|
||||
if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
|
||||
if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array));
|
||||
if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array));
|
||||
|
||||
sim.agents = state.agents || [];
|
||||
for (const a of sim.agents) sim.ensureAgentTech(a);
|
||||
|
|
@ -2227,6 +2538,11 @@ class Simulation {
|
|||
age: c.age || 0,
|
||||
strength: c.strength || 1,
|
||||
sedentaryCulture: c.sedentaryCulture ?? 0.5,
|
||||
knowledge: {
|
||||
farming: clamp(c.knowledge?.farming ?? 0, 0, 1),
|
||||
metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1)
|
||||
},
|
||||
supplyStress: c.supplyStress ?? 0,
|
||||
polityId: c.polityId ?? null,
|
||||
loyalty: c.loyalty ?? 0.5,
|
||||
receivedAid: c.receivedAid ?? false
|
||||
|
|
@ -2277,7 +2593,11 @@ function render() {
|
|||
const start = performance.now();
|
||||
const w = sim.world;
|
||||
const size = w.size;
|
||||
const image = ctx.createImageData(size, size);
|
||||
if (!renderImage || renderImageSize !== size) {
|
||||
renderImage = ctx.createImageData(size, size);
|
||||
renderImageSize = size;
|
||||
}
|
||||
const image = renderImage;
|
||||
const data = image.data;
|
||||
const mode = els.viewMode.value;
|
||||
|
||||
|
|
@ -2286,9 +2606,12 @@ function render() {
|
|||
if (mode === "resources") {
|
||||
const v = clamp(w.resource[i] / 30, 0, 1);
|
||||
color = mix([28, 36, 40], [107, 188, 85], v);
|
||||
} else if (mode === "pheromone") {
|
||||
const v = clamp(w.pheromone[i] / 9, 0, 1);
|
||||
color = w.tradeRoute[i] ? mix(terrainInfo[w.terrain[i]].color, [255, 218, 91], 0.38) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v);
|
||||
} else if (mode === "ethnicity") {
|
||||
const id = w.dominantEthnicity[i];
|
||||
const ethnicity = id >= 0 ? sim.ethnicities.get(id) : null;
|
||||
color = ethnicity
|
||||
? mix(ethnicity.color, [178, 184, 177], clamp(w.cultureDiversity[i] * 0.85, 0, 0.65))
|
||||
: mix(terrainInfo[w.terrain[i]].color, [18, 21, 23], 0.55);
|
||||
} else if (mode === "pressure") {
|
||||
const v = clamp(w.pressure[i] / 12, 0, 1);
|
||||
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
|
||||
|
|
@ -2460,6 +2783,7 @@ function renderTooltip() {
|
|||
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
|
||||
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
|
||||
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
|
||||
const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
|
||||
|
||||
els.tooltip.innerHTML = `
|
||||
<strong>${agent ? "Agent group" : terrain.name}</strong>
|
||||
|
|
@ -2472,12 +2796,15 @@ function renderTooltip() {
|
|||
<span><b>Minerals</b><em>${w.mineral[i].toFixed(2)}</em></span>
|
||||
<span><b>Pheromone</b><em>${w.pheromone[i].toFixed(1)}</em></span>
|
||||
<span><b>Pressure</b><em>${w.pressure[i].toFixed(0)}</em></span>
|
||||
<span><b>Local culture</b><em>${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}</em></span>
|
||||
${w.tradeRoute[i] ? `<span><b>Route</b><em>${w.tradeRoute[i]}</em></span>` : ""}
|
||||
${city ? `<span><b>City</b><em>#${city.id}</em></span>` : ""}
|
||||
${city ? `<span><b>Urban pop</b><em>${city.population.toLocaleString()}</em></span>` : ""}
|
||||
${city ? `<span><b>Food stock</b><em>${city.storedResources.toFixed(1)}</em></span>` : ""}
|
||||
${city ? `<span><b>Supply stress</b><em>${(city.supplyStress || 0).toFixed(2)}</em></span>` : ""}
|
||||
${city ? `<span><b>Farmland radius</b><em>${city.agriculturalRadius}</em></span>` : ""}
|
||||
${city ? `<span><b>Trade links</b><em>${city.tradeLinks.size}</em></span>` : ""}
|
||||
${city ? `<span><b>City knowledge</b><em>${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}</em></span>` : ""}
|
||||
${cityEthnicity ? `<span><b>City majority</b><em>E${cityEthnicity}</em></span>` : ""}
|
||||
${city ? `<span><b>State</b><em>${polity ? `#${polity.id}` : "Independent"}</em></span>` : ""}
|
||||
${city ? `<span><b>Loyalty</b><em>${city.loyalty.toFixed(2)}</em></span>` : ""}
|
||||
|
|
@ -2537,7 +2864,7 @@ function waterInfluenceAt(world, x, y) {
|
|||
|
||||
function updateStats(force = false) {
|
||||
const now = performance.now();
|
||||
if (!force && now - lastStatsAt < 300) return;
|
||||
if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return;
|
||||
lastStatsAt = now;
|
||||
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
|
||||
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
|
||||
|
|
@ -2577,9 +2904,7 @@ function setLegend() {
|
|||
if (mode === "terrain") {
|
||||
els.legend.innerHTML = terrainInfo.map(t => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join("");
|
||||
} else if (mode === "ethnicity") {
|
||||
els.legend.innerHTML = "<span>Color = inherited ethnicity. New colors appear only by recorded splits or assimilation.</span>";
|
||||
} else if (mode === "pheromone") {
|
||||
els.legend.innerHTML = "<span><i style=\"background:#e8c14b\"></i>Pheromone trails and permanent trade routes</span>";
|
||||
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") {
|
||||
|
|
@ -2595,7 +2920,7 @@ function setLegend() {
|
|||
|
||||
function maybeRenderStateGraph() {
|
||||
const now = performance.now();
|
||||
if (now - lastGraphRenderAt < 500) return;
|
||||
if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return;
|
||||
lastGraphRenderAt = now;
|
||||
renderStateGraph();
|
||||
}
|
||||
|
|
@ -2768,6 +3093,8 @@ function reset() {
|
|||
const count = Number(els.agentCount.value);
|
||||
els.canvas.width = size;
|
||||
els.canvas.height = size;
|
||||
renderImage = null;
|
||||
renderImageSize = 0;
|
||||
sim = new Simulation(size, count);
|
||||
setLegend();
|
||||
render();
|
||||
|
|
@ -2928,23 +3255,49 @@ function unpackArray(payload, TypedArray) {
|
|||
return new TypedArray(bytes.buffer, 0, payload.length);
|
||||
}
|
||||
|
||||
const Persistence = Object.freeze({
|
||||
save(currentSim) {
|
||||
const raw = JSON.stringify(currentSim);
|
||||
if (raw.length > SimConfig.save.maxBytes) {
|
||||
throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`);
|
||||
}
|
||||
localStorage.setItem(SimConfig.save.key, raw);
|
||||
},
|
||||
|
||||
load() {
|
||||
const raw = localStorage.getItem(SimConfig.save.key);
|
||||
if (!raw) return null;
|
||||
if (raw.length > SimConfig.save.maxBytes * 1.25) {
|
||||
throw new Error("Saved world is too large to load safely");
|
||||
}
|
||||
const state = JSON.parse(raw);
|
||||
if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields");
|
||||
return Simulation.fromJSON(state);
|
||||
},
|
||||
|
||||
clear() {
|
||||
localStorage.removeItem(SimConfig.save.key);
|
||||
}
|
||||
});
|
||||
|
||||
function saveWorld() {
|
||||
try {
|
||||
localStorage.setItem("civil-emergence-save", JSON.stringify(sim));
|
||||
Persistence.save(sim);
|
||||
} catch (error) {
|
||||
console.warn("Save failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorld() {
|
||||
const raw = localStorage.getItem("civil-emergence-save");
|
||||
if (!raw) return;
|
||||
try {
|
||||
const state = JSON.parse(raw);
|
||||
sim = Simulation.fromJSON(state);
|
||||
const loaded = Persistence.load();
|
||||
if (!loaded) return;
|
||||
sim = loaded;
|
||||
els.worldSize.value = String(sim.world.size);
|
||||
els.canvas.width = sim.world.size;
|
||||
els.canvas.height = sim.world.size;
|
||||
renderImage = null;
|
||||
renderImageSize = 0;
|
||||
setLegend();
|
||||
render();
|
||||
updateStats(true);
|
||||
|
|
@ -2968,7 +3321,7 @@ els.stepOnce.addEventListener("click", () => {
|
|||
els.resetWorld.addEventListener("click", reset);
|
||||
els.saveWorld.addEventListener("click", saveWorld);
|
||||
els.loadWorld.addEventListener("click", loadWorld);
|
||||
els.clearSave.addEventListener("click", () => localStorage.removeItem("civil-emergence-save"));
|
||||
els.clearSave.addEventListener("click", () => Persistence.clear());
|
||||
els.worldSize.addEventListener("change", reset);
|
||||
els.agentCount.addEventListener("change", reset);
|
||||
els.viewMode.addEventListener("change", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue