This commit is contained in:
33333-33333 2026-09-16 13:51:43 +09:00
commit e2d2dfcb66
7 changed files with 2610 additions and 1271 deletions

View file

@ -41,10 +41,11 @@ scale: null
};
const legendByMode = {
terrain: () => terrainInfo.map(t => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join(""),
ethnicity: () => "<span>Color = tile-level dominant ethnicity. Stronger color marks higher local population; mixed areas are muted.</span>",
pressure: () => "<span><i style=\"background:#d24943\"></i>High local population pressure</span>",
polities: () => "<span>Color = territorial state control. Brighter areas are stronger control; pale borders mark frontiers; contested areas are unstable.</span>",
technology: () => "<span><i style=\"background:#5fab5b\"></i>Farming knowledge</span><span><i style=\"background:#caa966\"></i>Metallurgy knowledge</span>",
pheromone: () => "<span><i style=\"background:#d8b156\"></i>Pheromone strength</span><span><i style=\"background:#ffd75a\"></i>Formal trade routes</span>",
resources: () => "<span><i style=\"background:#6bbc55\"></i>Regenerating local resource stock</span>"
ethnicity: () => "<span>Ethnicity</span>",
pressure: () => "<span>Pressure</span>",
polities: () => "<span>States</span>",
nomads: () => "<span>Nomads</span>",
technology: () => "<span>Technology</span>",
pheromone: () => "<span>Routes</span>",
resources: () => "<span>Resources</span>"
};

View file

@ -45,12 +45,12 @@ scheduleLoop();
}
function reset() {
const size = Number(els.worldSize.value);
const count = Number(els.agentCount.value);
const initialPopulation = Number(els.agentCount.value);
els.canvas.width = size;
els.canvas.height = size;
renderImage = null;
renderImageSize = 0;
sim = new Simulation(size, count);
sim = new Simulation(size, initialPopulation);
setLegend();
drawFrame(true);
renderStateGraph();

View file

@ -17,13 +17,8 @@ const MONTHS_PER_YEAR = 12;
const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH;
const SimConfig = Object.freeze({
population: Object.freeze({
maxAgentsFloor: 8000,
maxAgentsScale: 1.15,
softCapStart: 0.85,
hardCapScale: 1.35,
offspringCrowdingPenalty: 1.8,
minOffspringAcceptance: 0.035,
maxOffspringPerStep: 360,
carryingCapacityBase: 2.4,
carryingCapacityFertility: 7.8,
carryingCapacityMineral: 1.8,
@ -75,6 +70,40 @@ maxDurationYears: 25,
claimRadius: 8,
maxTargetDistance: 48
}),
nomad: Object.freeze({
enabled: true,
maxBands: 24,
enabledAfterYears: 120,
spawnCheckIntervalYears: 25,
baseSpawnChance: 0.28,
minPopulation: 80,
maxPopulation: 420,
minRadius: 5,
maxRadius: 14,
movementIntervalYears: 2,
maxMoveDistance: 9,
grazingGain: 0.035,
overgrazingDamage: 0.018,
resourceConsumption: 0.012,
herdSoftCapPerPop: 2.4,
herdHardCapPerPop: 4.0,
resourceSoftCapPerPop: 1.2,
resourceHardCapPerPop: 2.5,
grazingDiminishingPower: 1.35,
urbanAbsorptionControl: 0.55,
cityInteractionRadius: 8,
tradeChance: 0.22,
raidChance: 0.14,
invasionChance: 0.025,
confederationPopulation: 900,
confederationPrestige: 1.8,
splitPopulation: 1200,
sedentarizationChance: 0.035,
collapseCohesion: 0.12,
influenceCulturalTrace: 0.006,
influenceMobilePopulationTrace: 0.004,
visualDurationYears: 20
}),
polityAccess: Object.freeze({
enabled: true,
maxSearchDepth: 8,
@ -92,6 +121,13 @@ minimumInfluence: 0.18
}),
route: Object.freeze({
maxRouteLength: 42,
adaptiveMaxRouteLength: 72,
isolatedCityBootstrap: true,
isolatedRouteSearchLimit: 8,
isolatedRouteRadius: 4,
newRoutePheromoneSeed: 2.5,
newRouteGraceBoost: 18,
isolatedRouteMinStrength: 0.12,
pheromoneDecay: 0.996,
pheromoneDiffusion: 0.045,
maxPheromone: 18,
@ -99,9 +135,20 @@ routePheromoneBuild: 0.18,
routePheromoneMaintain: 0.12,
routeUpkeepPerTile: 0.018,
routeWeakThreshold: 8,
routeUnsupportedDecay: 7,
routeUnsupportedDecay: 3,
routeMaintainedBoost: 10
}),
war: Object.freeze({
pressureMemoryDecay: 0.88,
cityDamageMinRate: 0.003,
cityDamageMaxRate: 0.08,
captureLossBase: 0.08,
captureLossMax: 0.32,
exhaustionPopulationLossBase: 0.0007,
exhaustionPopulationLossMax: 0.012,
warZoneTileLossRate: 0.006,
majorSettlementThreshold: 1.25
}),
disaster: Object.freeze({
damageScale: 1.5,
majorLossRate: 0.05,
@ -122,13 +169,9 @@ frontierWave: Object.freeze({
enabledAfterYears: 300,
intervalYears: 80,
chance: 0.45,
minAgents: 60,
maxAgents: 140,
maxPopulationRatio: 1.28,
edgeBand: 4,
mutation: 0.08,
minResources: 12,
maxResources: 28
minPopulation: 60,
maxPopulation: 140,
edgeBand: 4
})
});
function years(value) {

3575
engine.js

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@
</label>
<label>
Initial groups
Initial population
<input id="agentCount" type="range" min="500" max="20000" step="500" value="5000">
</label>
@ -57,7 +57,7 @@
<section class="panel stats" aria-live="polite">
<dl>
<div><dt>Date</dt><dd id="year">0y 1m</dd></div>
<div><dt>Active groups</dt><dd id="activeGroups">0</dd></div>
<div><dt>Field population</dt><dd id="activeGroups">0</dd></div>
<div><dt>Urban population</dt><dd id="urbanPopulation">0</dd></div>
<div><dt>Ethnicities</dt><dd id="ethnicities">0</dd></div>
<div><dt>Cities</dt><dd id="cities">0</dd></div>
@ -66,7 +66,7 @@
<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>
<div><dt>Collapse deaths</dt><dd id="deaths">0</dd></div>
<div><dt>Deaths</dt><dd id="deaths">0</dd></div>
<div><dt>Frame cost</dt><dd id="frameCost">0ms</dd></div>
</dl>
</section>

200
render.js
View file

@ -79,7 +79,13 @@ if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(
}
}
} else if (mode === "technology") {
color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
const farming = w.farmingKnowledge?.[i] || 0;
const metallurgy = w.metallurgyKnowledge?.[i] || 0;
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
const techColor = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
color = tech > 0.01
? mix(terrainInfo[w.terrain[i]].color, techColor, clamp(0.12 + tech * 0.62, 0.12, 0.74))
: mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
} else if (mode === "pheromone") {
const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v);
@ -95,8 +101,9 @@ data[p + 3] = 255;
ctx.putImageData(image, 0, 0);
renderDisasters();
drawTradeLinks();
drawNomadBands();
drawCampaigns();
drawAgentsAndCities(mode);
drawPopulationAndCities(mode);
drawGraphPolityHighlight();
maybeRenderStateGraph();
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
@ -205,7 +212,39 @@ fillSquare(ctx, x, y, 1);
}
ctx.restore();
}
function drawAgentsAndCities(mode) {
function drawNomadBands() {
if (!sim?.nomadBands?.length) return;
const w = sim.world;
ctx.save();
for (const band of sim.nomadBands) {
const ethnicityColor = sim.ethnicities.get(band.ethnicityId)?.color || [216, 177, 86];
ctx.globalAlpha = 0.07;
ctx.fillStyle = rgba(ethnicityColor, 0.45);
for (const tile of band.influenceTiles || []) {
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
ctx.globalAlpha = 0.34;
ctx.strokeStyle = band.mode === "raiding" || band.mode === "invading"
? "rgba(232, 101, 76, 0.95)"
: band.mode === "settling"
? "rgba(202, 169, 102, 0.9)"
: rgba(ethnicityColor, 0.9, 18);
ctx.lineWidth = 1;
strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(2.5, band.radius * 0.45));
if (band.mode === "confederating") {
ctx.globalAlpha = 0.26;
strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(4, band.radius * 0.68));
}
ctx.globalAlpha = 0.22;
ctx.fillStyle = rgba(ethnicityColor, 0.9, 22);
for (const tile of band.trailTiles || []) ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
ctx.globalAlpha = 0.82;
ctx.fillStyle = rgba(ethnicityColor, 0.95, 36);
fillSquare(ctx, Math.round(band.x), Math.round(band.y), 1);
}
ctx.restore();
}
function drawPopulationAndCities(mode) {
ctx.save();
if (mode !== "ethnicity") {
for (const city of sim.cities) {
@ -218,23 +257,18 @@ fillSquare(ctx, city.x, city.y, radius);
}
ctx.globalAlpha = 1;
if (mode === "technology") {
for (const a of sim.agents) {
sim.ensureAgentTech(a);
const farming = techLevel(a, "farming");
const metallurgy = techLevel(a, "metallurgy");
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
if (tech <= 0.01) continue;
const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1));
ctx.fillRect(a.x, a.y, 1, 1);
}
// Tile-level technology is rendered in the base pass.
} else if (mode === "ethnicity") {
// Phase 2: ethnicity view is driven by tile-level population/culture fields.
// Individual agents remain in the simulation but no longer define this layer.
// Ethnicity view is driven by tile-level population/culture fields.
} else {
ctx.fillStyle = "#eeeccf";
for (const tile of sim.tileAgents.keys()) {
ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
const w = sim.world;
ctx.fillStyle = "rgba(238, 236, 207, 0.16)";
for (let tile = 0; tile < w.count; tile++) {
const pop = w.population?.[tile] || 0;
if (pop < 6) continue;
const alpha = clamp(Math.log1p(pop) / Math.log(160), 0.04, 0.18);
ctx.fillStyle = `rgba(238, 236, 207, ${alpha})`;
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
}
for (const city of sim.cities) {
@ -329,23 +363,23 @@ return;
}
const w = sim.world;
const i = w.idx(x, y);
const agent = findAgentAt(x, y);
const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null;
const terrain = terrainInfo[w.terrain[i]];
const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
const polity = cityPolity(city);
const territoryId = w.polity?.[i] ?? -1;
const territoryPolity = territoryId >= 0 ? sim.getPolityById(territoryId) : null;
const ethnicMix = sim.tileEthnicMix?.get(i);
const tileDominantEthnicity = ethnicMix?.size ? dominantComposition(ethnicMix) : w.dominantEthnicity[i];
const regionalEthnicity = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? `E${tileDominantEthnicity}` : "-";
const regionalLineage = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.ethnicities.get(tileDominantEthnicity) : null;
const mismatch = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.climateMismatch(tileDominantEthnicity, i) : 0;
const tilePopulation = w.population?.[i] || 0;
const nearbyCampaigns = activeCampaignsNear(i);
const nearbyNomad = activeNomadNear(i);
els.tooltip.innerHTML = `
<strong>${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}</strong>
<strong>${city ? `City #${city.id}` : terrain.name}</strong>
${tooltipSection("Tile", [
tooltipRow("Position", `${x}, ${y}`),
tooltipRow("Terrain", terrain.name),
@ -367,6 +401,16 @@ els.tooltip.innerHTML = `
tooltipRow("Dominant ethnicity", regionalEthnicity),
tooltipRow("Tile population", tilePopulation >= 10 ? Math.round(tilePopulation).toLocaleString() : tilePopulation.toFixed(1))
])}
${tooltipSection("Nomads", [
tooltipRow("Band", nearbyNomad ? `${nearbyNomad.name} #${nearbyNomad.id}` : "", nearbyNomad),
tooltipRow("Mode", nearbyNomad?.mode, nearbyNomad),
tooltipRow("Population", nearbyNomad ? Math.round(nearbyNomad.population).toLocaleString() : "", nearbyNomad),
tooltipRow("Ethnicity", nearbyNomad ? `E${nearbyNomad.ethnicityId}` : "", nearbyNomad),
tooltipRow("Cohesion", nearbyNomad?.cohesion.toFixed(2), nearbyNomad),
tooltipRow("Prestige", nearbyNomad?.prestige.toFixed(2), nearbyNomad),
tooltipRow("Resources", nearbyNomad?.resources.toFixed(1), nearbyNomad),
tooltipRow("Herds", nearbyNomad?.herds.toFixed(1), nearbyNomad)
])}
${tooltipSection("City & State", [
tooltipRow("Population", city?.population.toLocaleString(), city),
tooltipRow("Food stock", city?.storedResources.toFixed(1), city),
@ -382,17 +426,15 @@ els.tooltip.innerHTML = `
tooltipRow("Treasury", polity?.treasury.toFixed(1), polity),
tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity)
])}
${tooltipSection("Culture & Agent", [
${tooltipSection("Culture", [
tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`),
tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent),
tooltipRow("Lineage pop", ethnicity?.population, ethnicity),
tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity),
tooltipRow("Climate mismatch", mismatch.toFixed(2), agent),
tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent),
tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent),
tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent),
tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent),
tooltipRow("Stored", agent?.resources.toFixed(1), agent)
tooltipRow("Lineage pop", regionalLineage?.population?.toLocaleString(), regionalLineage),
tooltipRow("Climate pref", regionalLineage ? `${regionalLineage.climateTemp.toFixed(2)} / ${regionalLineage.climateHumidity.toFixed(2)}` : "", regionalLineage),
tooltipRow("Climate mismatch", mismatch.toFixed(2), regionalLineage),
tooltipRow("Settled pop", (w.settledPopulation?.[i] || 0).toFixed(1), tilePopulation > 0),
tooltipRow("Mobile pop", (w.mobilePopulation?.[i] || 0).toFixed(1), tilePopulation > 0),
tooltipRow("Farming", (w.farmingKnowledge?.[i] || 0).toFixed(3)),
tooltipRow("Metallurgy", (w.metallurgyKnowledge?.[i] || 0).toFixed(3))
])}
`;
els.tooltip.hidden = false;
@ -459,9 +501,23 @@ labels.push(`${campaign.type.replaceAll("_", " ")} ${Math.round(clamp(campaign.p
}
return labels.slice(0, 2).join("; ");
}
function findAgentAt(x, y) {
const directIndex = sim.world.idx(x, y);
return sim.tileAgents.get(directIndex)?.[0] || null;
function activeNomadNear(tile) {
if (!sim?.nomadBands?.length) return null;
const w = sim.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let best = null;
let bestDistance = Infinity;
for (const band of sim.nomadBands) {
const distance = Math.abs(Math.round(band.x) - x) + Math.abs(Math.round(band.y) - y);
const inInfluence = w.nomadBand?.[tile] === band.id;
if (!inInfluence && distance > Math.max(3, Math.floor(band.radius * 0.55))) continue;
if (distance < bestDistance) {
best = band;
bestDistance = distance;
}
}
return best;
}
function waterInfluenceAt(world, x, y) {
let score = 0;
@ -487,25 +543,29 @@ let farmingTotal = 0;
let metallurgyTotal = 0;
let farmingHolders = 0;
let metallurgyHolders = 0;
for (const a of sim.agents) {
sim.ensureAgentTech(a);
farmingTotal += techLevel(a, "farming");
metallurgyTotal += techLevel(a, "metallurgy");
if (techLevel(a, "farming") > 0.02) farmingHolders++;
if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++;
for (let i = 0; i < sim.world.count; i++) {
const pop = sim.world.population?.[i] || 0;
if (pop <= 0) continue;
farmingTotal += (sim.world.farmingKnowledge?.[i] || 0) * pop;
metallurgyTotal += (sim.world.metallurgyKnowledge?.[i] || 0) * pop;
if ((sim.world.farmingKnowledge?.[i] || 0) > 0.02) farmingHolders++;
if ((sim.world.metallurgyKnowledge?.[i] || 0) > 0.02) metallurgyHolders++;
}
const agentCount = Math.max(1, sim.agents.length);
const fieldPopulation = sim.world.population
? Array.from(sim.world.population).reduce((sum, value) => sum + value, 0)
: 0;
const knowledgeDenominator = Math.max(1, fieldPopulation);
const stats = {
year: formatSimDate(sim.year),
activeGroups: sim.agents.length.toLocaleString(),
activeGroups: Math.round(fieldPopulation).toLocaleString(),
urbanPopulation: Math.floor(urbanPopulation).toLocaleString(),
ethnicities: livingEthnicities.length.toLocaleString(),
cities: sim.cities.length.toLocaleString(),
polities: sim.polities.length.toLocaleString(),
wars: sim.wars.length.toLocaleString(),
routes: sim.tradeLinks.length.toLocaleString(),
farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`,
metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`,
farmingKnowledge: `${(farmingTotal / knowledgeDenominator).toFixed(4)} (${farmingHolders})`,
metallurgyKnowledge: `${(metallurgyTotal / knowledgeDenominator).toFixed(4)} (${metallurgyHolders})`,
deaths: sim.deaths.toLocaleString()
};
for (const [id, value] of Object.entries(stats)) {
@ -880,7 +940,7 @@ newEthnicity: {
label: "new ethnicity",
color: "rgba(105, 181, 120, 0.95)",
draw: drawGraphDiamond,
summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups`
summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier population`
},
campaign: {
label: "campaign",
@ -924,6 +984,54 @@ color: "rgba(236, 126, 111, 0.95)",
draw: drawGraphTriangleUp,
summary: data => `${data.outcome || "war"} vs S${data.targetPolityId ?? "-"}`
},
nomadBand: {
label: "nomad band",
color: "rgba(210, 184, 104, 0.95)",
draw: drawGraphDiamond,
summary: data => `band #${data.bandId ?? "-"}, E${data.ethnicity ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}`
},
nomadTrade: {
label: "nomad trade",
color: "rgba(216, 177, 86, 0.95)",
draw: drawGraphCircle,
summary: data => `band #${data.bandId ?? "-"} with city #${data.cityId ?? "-"}`
},
nomadRaid: {
label: "nomad raid",
color: "rgba(232, 101, 76, 0.95)",
draw: drawGraphTriangleDown,
summary: data => `band #${data.bandId ?? "-"} raided city #${data.cityId ?? "-"}`
},
nomadConquest: {
label: "nomad conquest",
color: "rgba(202, 87, 78, 0.95)",
draw: drawGraphTriangleUp,
summary: data => `band #${data.bandId ?? "-"}, city #${data.cityId ?? "-"}, ${data.outcome || "conquest"}`
},
nomadConfederation: {
label: "nomad confederation",
color: "rgba(191, 162, 91, 0.95)",
draw: drawGraphDiamond,
summary: data => `band #${data.bandId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}`
},
nomadSplit: {
label: "nomad split",
color: "rgba(151, 130, 91, 0.95)",
draw: drawGraphTriangleDown,
summary: data => `band #${data.bandId ?? "-"} split #${data.newBandId ?? "-"}`
},
nomadMerge: {
label: "nomad merge",
color: "rgba(170, 145, 91, 0.95)",
draw: drawGraphDiamond,
summary: data => `band #${data.mergedBandId ?? "-"} joined #${data.bandId ?? "-"}`
},
sedentarization: {
label: "sedentarization",
color: "rgba(202, 169, 102, 0.95)",
draw: drawGraphCircle,
summary: data => `band #${data.bandId ?? "-"} settled ${Math.round(data.population || 0).toLocaleString()} in city #${data.cityId ?? "-"}`
},
war: {
label: "war",
color: "rgba(202, 87, 78, 0.95)",

View file

@ -43,7 +43,9 @@ function averageTraits(sum, count) {
return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale]));
}
function compositionTotal(composition) {
return [...composition.values()].reduce((sum, value) => sum + value, 0);
let sum = 0;
for (const value of composition.values()) sum += value;
return sum;
}
function addBirthsToComposition(composition, births, rng = null) {
const entries = [...composition.entries()];