finally removed agent system

This commit is contained in:
33333-33333 2026-05-18 17:13:18 +09:00
commit 79a0c29f84

232
engine.js
View file

@ -1806,7 +1806,6 @@ this.cityById.set(city.id, city);
const bucketKey = this.cityBucketKey(city.x, city.y); const bucketKey = this.cityBucketKey(city.x, city.y);
if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
this.cityBuckets.get(bucketKey).push(city); this.cityBuckets.get(bucketKey).push(city);
this.assignNewCityToTerritoryOwner(city, i);
foundedThisTick++; foundedThisTick++;
} }
if (city) { if (city) {
@ -1873,42 +1872,6 @@ w.mineral[tile] * 0.9 -
w.move[tile] * 1.1 - w.move[tile] * 1.1 -
nearbyPenalty; nearbyPenalty;
} }
assignNewCityToTerritoryOwner(city, tile) {
const w = this.world;
const ownerId = w.polity?.[tile] ?? -1;
if (!city || ownerId < 0) return false;
const polity = this.getPolityById(ownerId);
if (!polity) return false;
if (city.polityId != null && city.polityId !== ownerId) return false;
const control = clamp(w.control?.[tile] || 0, 0, 1);
const contested = !!w.contested?.[tile];
if (contested && control < 0.35) return false;
const cityEthnicity = this.dominantCityEthnicity(city);
const nearest = this.nearestPolityCityToTile(polity, tile);
const core = nearest || this.getCityById(polity.centerCityId);
const coreEthnicity = this.dominantCityEthnicity(core);
const sameEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity === coreEthnicity;
const differentEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity !== coreEthnicity;
const distance = nearest ? Math.abs(nearest.x - city.x) + Math.abs(nearest.y - city.y) : 24;
let loyalty = 0.38 + control * 0.34 - (contested ? 0.18 : 0) + (sameEthnicity ? 0.10 : 0) - (differentEthnicity ? 0.10 : 0) - clamp((distance - 18) * 0.004, 0, 0.10);
this.addCityToPolity(city, polity, clamp(loyalty, 0.18, 0.82));
return true;
}
nearestPolityCityToTile(polity, tile) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let best = null;
let bestDistance = Infinity;
for (const city of this.getPolityCities(polity)) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (distance < bestDistance) {
best = city;
bestDistance = distance;
}
}
return best;
}
drawPopulationFromTilesForCity(city, group, amount) { drawPopulationFromTilesForCity(city, group, amount) {
const w = this.world; const w = this.world;
let remaining = Math.max(0, amount); let remaining = Math.max(0, amount);
@ -2196,10 +2159,8 @@ const capitalDistance = center ? this.effectiveDistance(center, city) : 0;
const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1); const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1);
const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58; const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58;
const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22); const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22);
const popScale = clamp(Math.log1p(city.population || 1) / Math.log(1200), 0, 1.6); const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor;
const populationRadiusFactor = clamp(0.55 + popScale * 0.75, 0.55, 1.45); const radius = clamp(Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45))), 6, maxRange);
const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor * (1 + popScale * 0.32);
const radius = clamp(Math.ceil(maxRange * populationRadiusFactor + (city.id === polity.centerCityId ? 2 : 0)), 5, 48);
for (let dy = -radius; dy <= radius; dy++) { for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) { for (let dx = -radius; dx <= radius; dx++) {
const manhattan = Math.abs(dx) + Math.abs(dy); const manhattan = Math.abs(dx) + Math.abs(dy);
@ -2210,16 +2171,15 @@ if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y); const tile = w.idx(x, y);
const isWater = w.terrain[tile] === Terrain.WATER; const isWater = w.terrain[tile] === Terrain.WATER;
if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue; if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue;
const routeBoost = w.tradeRoute[tile] ? 1.34 + clamp(city.tradeValue || 0, 0, 2) * 0.08 : 1; const routeBoost = w.tradeRoute[tile] ? 1.28 : 1;
const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1); const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1);
const effectiveDistance = (manhattan + terrainDrag * 1.4) / routeBoost; const effectiveDistance = (manhattan + terrainDrag * 1.4) / routeBoost;
const distanceFalloff = clamp(1 - effectiveDistance / (radius + 4), 0, 1); const distanceFalloff = clamp(1 - effectiveDistance / (radius + 4), 0, 1);
if (distanceFalloff <= 0) continue; if (distanceFalloff <= 0) continue;
const localEthnicity = this.dominantTileEthnicity(tile); const localEthnicity = w.dominantEthnicity[tile];
const ethnicityFactor = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? 0.88 : 1.07; const ethnicityFactor = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? 0.82 : 1.06;
const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 20, 0, 0.28); const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 20, 0, 0.28);
const coreBoost = manhattan <= Math.max(2, Math.floor(2 + popScale * 3)) ? 1.25 + popScale * 0.22 : 1; const score = baseInfluence * distanceFalloff * distanceFalloff * routeBoost * ethnicityFactor * (1 + populationAnchor) / (1 + terrainDrag * 0.18);
const score = baseInfluence * distanceFalloff * distanceFalloff * routeBoost * ethnicityFactor * coreBoost * (1 + populationAnchor) / (1 + terrainDrag * 0.16);
if (score <= 0.8) continue; if (score <= 0.8) continue;
if (score > best[tile]) { if (score > best[tile]) {
if (owner[tile] !== polity.id) second[tile] = best[tile]; if (owner[tile] !== polity.id) second[tile] = best[tile];
@ -2237,7 +2197,7 @@ for (let i = 0; i < w.count; i++) {
if (owner[i] < 0 || best[i] < threshold) continue; if (owner[i] < 0 || best[i] < threshold) continue;
w.polity[i] = owner[i]; w.polity[i] = owner[i];
w.claim[i] = clamp(best[i] / 22, 0, 1); w.claim[i] = clamp(best[i] / 22, 0, 1);
w.control[i] = clamp((best[i] - threshold) / 15, 0.12, 1); w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1);
if (second[i] > best[i] * 0.72) w.contested[i] = 1; if (second[i] > best[i] * 0.72) w.contested[i] = 1;
} }
this.applyStoredTerritorialClaims(); this.applyStoredTerritorialClaims();
@ -2305,15 +2265,14 @@ return stats;
campaignPressureOnTerritories() { campaignPressureOnTerritories() {
const w = this.world; const w = this.world;
const cfg = SimConfig.campaign || {}; const cfg = SimConfig.campaign || {};
const radius = cfg.claimRadius ?? 8;
for (const campaign of this.campaigns || []) { for (const campaign of this.campaigns || []) {
if (campaign.status !== "active" || campaign.targetTile == null) continue; if (campaign.status !== "active" || campaign.targetTile == null) continue;
const attacker = this.getPolityById(campaign.attackerPolityId); const attacker = this.getPolityById(campaign.attackerPolityId);
if (!attacker) continue; if (!attacker) continue;
const radius = campaign.type === "border_war" ? Math.max(5, Math.floor((cfg.claimRadius ?? 8) * 1.25)) : cfg.claimRadius ?? 8;
if (!campaign.affectedTiles?.length) campaign.affectedTiles = this.campaignAffectedTiles(campaign.targetTile, radius);
const cx = campaign.targetTile % w.size; const cx = campaign.targetTile % w.size;
const cy = Math.floor(campaign.targetTile / w.size); const cy = Math.floor(campaign.targetTile / w.size);
const pressure = clamp(0.10 + campaign.progress * (campaign.type === "border_war" ? 0.38 : 0.28), 0.08, campaign.type === "border_war" ? 0.52 : 0.42); const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42);
for (let dy = -radius; dy <= radius; dy++) { for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) { for (let dx = -radius; dx <= radius; dx++) {
const distance = Math.abs(dx) + Math.abs(dy); const distance = Math.abs(dx) + Math.abs(dy);
@ -2336,25 +2295,6 @@ w.contested[tile] = 1;
} }
} }
} }
campaignAffectedTiles(centerTile, radius) {
const w = this.world;
const tiles = [];
if (centerTile == null) return tiles;
const cx = centerTile % w.size;
const cy = Math.floor(centerTile / w.size);
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 x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
const tile = w.idx(x, y);
if (w.terrain[tile] !== Terrain.WATER) tiles.push(tile);
}
}
return tiles;
}
getCityById(id) { getCityById(id) {
const cached = this.cityById?.get(id); const cached = this.cityById?.get(id);
if (cached) return cached; if (cached) return cached;
@ -2385,14 +2325,6 @@ dominantCityEthnicity(city) {
if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null; if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
return dominantComposition(city.ethnicityComposition) || null; return dominantComposition(city.ethnicityComposition) || null;
} }
dominantTileEthnicity(tile) {
const mix = this.tileEthnicMix?.get(tile);
if (mix?.size) {
const dominant = dominantComposition(mix);
if (dominant != null) return dominant;
}
return this.world.dominantEthnicity?.[tile] ?? -1;
}
sameDominantEthnicity(cityA, cityB) { sameDominantEthnicity(cityA, cityB) {
const a = this.dominantCityEthnicity(cityA); const a = this.dominantCityEthnicity(cityA);
const b = this.dominantCityEthnicity(cityB); const b = this.dominantCityEthnicity(cityB);
@ -2478,8 +2410,6 @@ const polity = {
id, id,
centerCityId: centerCity.id, centerCityId: centerCity.id,
cityIds: new Set([centerCity.id]), cityIds: new Set([centerCity.id]),
maxCityCount: 1,
everHadMultipleCities: false,
treasury: Math.max(0, centerCity.storedResources * 0.12), treasury: Math.max(0, centerCity.storedResources * 0.12),
color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
founded: this.year, founded: this.year,
@ -2801,8 +2731,6 @@ city.polityId = polity.id;
city.loyalty = clamp(initialLoyalty, 0, 1); city.loyalty = clamp(initialLoyalty, 0, 1);
city.receivedAid = false; city.receivedAid = false;
polity.cityIds.add(city.id); polity.cityIds.add(city.id);
polity.maxCityCount = Math.max(polity.maxCityCount || 0, polity.cityIds.size);
if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true;
} }
removeCityFromPolity(city) { removeCityFromPolity(city) {
if (!city || city.polityId === null) return; if (!city || city.polityId === null) return;
@ -3169,7 +3097,7 @@ return this.sampleTileCampaignTargets(polity, (tile, connection) => {
const w = this.world; const w = this.world;
if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null;
const population = w.population?.[tile] || w.pressure[tile] || 0; const population = w.population?.[tile] || w.pressure[tile] || 0;
const ethnicity = this.dominantTileEthnicity(tile); const ethnicity = w.dominantEthnicity[tile];
if (population < 2 && ethnicity < 0) return null; if (population < 2 && ethnicity < 0) return null;
const diversity = w.cultureDiversity[tile] || 0; const diversity = w.cultureDiversity[tile] || 0;
const sourceEthnicity = this.dominantCityEthnicity(connection.source); const sourceEthnicity = this.dominantCityEthnicity(connection.source);
@ -3209,21 +3137,20 @@ const targets = [];
for (const other of this.polities) { for (const other of this.polities) {
if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue; if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue;
const distance = this.polityDistance(polity, other); const distance = this.polityDistance(polity, other);
if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48) * 1.25) continue; if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue;
const border = this.nearestBorderCampaignTile(polity, other); const border = this.nearestBorderCampaignTile(polity, other);
const city = this.borderWarTargetCity(polity, other); const city = this.borderWarTargetCity(polity, other);
const defenderPower = Math.max(1, this.polityPower(other)); const defenderPower = Math.max(1, this.polityPower(other));
const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.32 + (city.tradeValue || 0) * 0.85 + (city.id === other.centerCityId ? 2.2 : 0) : 0.25; const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25;
const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0; const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0;
const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue; const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue;
const defenderWeakness = clamp(1 - (other.cohesion ?? 0.6), 0, 0.8) + clamp((other.crisis || 0) * 0.25, 0, 0.5);
const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0; const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0;
const score = borderValue + defenderWeakness - defenderPower / 110 - distance * 0.014 + this.polityPower(polity) / 120 + riskAppetite; const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite;
targets.push({ targets.push({
targetTile: city ? this.world.idx(city.x, city.y) : border.tile, targetTile: city ? this.world.idx(city.x, city.y) : border.tile,
targetCityId: city?.id ?? null, targetCityId: city?.id ?? null,
defenderPolityId: other.id, defenderPolityId: other.id,
weight: this.noisyTargetWeight(score) * (city ? 2.35 : 0.75) weight: this.noisyTargetWeight(score)
}); });
} }
return targets; return targets;
@ -3284,42 +3211,14 @@ if (connection.distance < best.distance) best = { tile: i, distance: connection.
return best; return best;
} }
borderWarTargetCity(attacker, defender) { borderWarTargetCity(attacker, defender) {
const maxDistance = (SimConfig.campaign?.maxTargetDistance ?? 48) * 1.25; const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48;
let best = null; let best = null;
let bestScore = -Infinity; let bestDistance = Infinity;
for (const city of this.getPolityCities(defender)) { for (const city of this.getPolityCities(defender)) {
const connection = this.polityConnectionToCity(attacker, city); const connection = this.polityConnectionToCity(attacker, city);
const direct = connection.source ? connection.distance : this.nearestPolityTileConnection(attacker, this.world.idx(city.x, city.y)).distance; if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) {
const routeFactor = connection.source && this.hasDirectTradeConnection(connection.source, city) ? 0.62 : 1;
const effectiveDistance = direct * routeFactor;
if (!Number.isFinite(effectiveDistance) || effectiveDistance > maxDistance) continue;
const contested = this.world.contested[this.world.idx(city.x, city.y)] ? 0.8 : 0;
const value = Math.sqrt(city.population || 1) * 0.28 + (city.tradeValue || 0) * 0.9 + (city.id === defender.centerCityId ? 2.4 : 0) + contested - effectiveDistance * 0.045;
if (value > bestScore) {
best = city; best = city;
bestScore = value; bestDistance = connection.distance;
}
}
return best;
}
nearestEnemyCityNearTile(attacker, defender, tile, radius) {
const w = this.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let best = null;
let bestScore = -Infinity;
for (const city of this.getPolityCities(defender)) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (distance > radius) continue;
const connection = this.polityConnectionToCity(attacker, city);
const routeFactor = connection.connected && connection.source && this.hasDirectTradeConnection(connection.source, city) ? 0.70 : 1;
const effectiveDistance = Math.min(distance, connection.distance * routeFactor);
const cityTile = w.idx(city.x, city.y);
const value = Math.sqrt(city.population || 1) * 0.30 + (city.tradeValue || 0) * 0.75 + (city.id === defender.centerCityId ? 2.0 : 0) + (w.contested[cityTile] ? 0.85 : 0);
const score = value - effectiveDistance * 0.055;
if (score > bestScore) {
best = city;
bestScore = score;
} }
} }
return best; return best;
@ -3342,17 +3241,8 @@ expires: this.year + duration,
progress: 0, progress: 0,
strength: 0, strength: 0,
resistance: 0, resistance: 0,
status: "active", status: "active"
affectedTiles: [],
frontLineTiles: []
}; };
const visualRadius = campaign.type === "border_war"
? Math.max(5, Math.floor((cfg.claimRadius ?? 8) * 1.25))
: Math.max(3, Math.floor((cfg.claimRadius ?? 8) * 0.75));
campaign.affectedTiles = this.campaignAffectedTiles(campaign.targetTile, visualRadius);
campaign.frontLineTiles = campaign.type === "border_war"
? campaign.affectedTiles.filter(tile => this.isBorderTile(tile) || this.world.contested[tile])
: [];
this.campaigns.push(campaign); this.campaigns.push(campaign);
polity.lastCampaignYear = this.year; polity.lastCampaignYear = this.year;
polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45)); polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45));
@ -3417,13 +3307,13 @@ const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign
const connection = this.nearestPolityTileConnection(attacker, tile); const connection = this.nearestPolityTileConnection(attacker, tile);
const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1); const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1);
const population = targetCity?.population || w.population[tile] || w.pressure[tile] || 0; const population = targetCity?.population || w.population[tile] || w.pressure[tile] || 0;
const defenderPower = defender ? this.polityPower(defender) * (campaign.type === "border_war" && targetCity ? 0.42 : 0.55) : 0; const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0;
const attackerEthnicity = this.dominantCityEthnicity(connection.source); const attackerEthnicity = this.dominantCityEthnicity(connection.source);
const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : this.dominantTileEthnicity(tile); const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile];
const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92; const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92;
const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1; const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1;
return Math.max(0.2, return Math.max(0.2,
(Math.sqrt(Math.max(1, population)) * (campaign.type === "border_war" && targetCity ? 2.75 : 3.2) + terrain * 8 + connection.distance * 0.32 + defenderPower) * (Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) *
mismatch * mismatch *
foreignControl * foreignControl *
(1 + (w.cultureDiversity[tile] || 0) * 0.35) (1 + (w.cultureDiversity[tile] || 0) * 0.35)
@ -3444,35 +3334,15 @@ this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData
} else if (campaign.type === "border_war") { } else if (campaign.type === "border_war") {
const defender = this.getPolityById(campaign.defenderPolityId); const defender = this.getPolityById(campaign.defenderPolityId);
const city = this.getCityById(campaign.targetCityId); const city = this.getCityById(campaign.targetCityId);
let capturedCity = false;
if (city && defender && city.polityId === defender.id) { if (city && defender && city.polityId === defender.id) {
capturedCity = true;
this.removeCityFromPolity(city); this.removeCityFromPolity(city);
this.addCityToPolity(city, attacker, 0.24); this.addCityToPolity(city, attacker, 0.22);
const capturedTile = this.world.idx(city.x, city.y);
this.applyCampaignTerritory(attacker, capturedTile, Math.max(8, (SimConfig.campaign?.claimRadius ?? 8) + 4), 0.68);
for (const nearby of this.getCitiesNear(city.x, city.y, 14)) {
if (nearby.polityId === defender.id) nearby.loyalty = clamp((nearby.loyalty ?? 0.5) - 0.09, 0, 1);
}
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.045, 0, 1);
this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, "capturedCity"), 3);
} else { } else {
this.applyCampaignTerritory(attacker, campaign.targetTile, Math.max(6, SimConfig.campaign?.claimRadius ?? 8), 0.58); this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48);
if (defender) {
const followUp = this.nearestEnemyCityNearTile(attacker, defender, campaign.targetTile, SimConfig.campaign?.maxTargetDistance ?? 48);
if (followUp && campaign.progress > 0.85 && this.rng.next() < 0.55) {
this.startCampaign(attacker, {
type: "border_war",
defenderPolityId: defender.id,
targetCityId: followUp.id,
targetTile: this.world.idx(followUp.x, followUp.y)
});
}
}
} }
if (defender) { if (defender) {
defender.cohesion = clamp((defender.cohesion ?? 0.6) - (capturedCity ? 0.075 : 0.045), 0, 1); defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1);
defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * (capturedCity ? 0.065 : 0.045)); defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04);
} }
this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2); this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2);
} else if (campaign.type === "nonstate_subjugation") { } else if (campaign.type === "nonstate_subjugation") {
@ -3514,7 +3384,7 @@ campaignEthnicSimilarity(polity, campaign) {
const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source; const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source;
const targetCity = this.getCityById(campaign.targetCityId); const targetCity = this.getCityById(campaign.targetCityId);
const a = this.dominantCityEthnicity(source); const a = this.dominantCityEthnicity(source);
const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.dominantTileEthnicity(campaign.targetTile); const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile];
if (a === null || b === null || b < 0) return 0.45; if (a === null || b === null || b < 0) return 0.45;
return a === b ? 1 : 0.25; return a === b ? 1 : 0.25;
} }
@ -3546,7 +3416,7 @@ contested: false
} }
} }
applySubjugationEffects(polity, campaign) { applySubjugationEffects(polity, campaign) {
const localEthnicity = campaign.targetEthnicity ?? this.dominantTileEthnicity(campaign.targetTile); const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile];
if (localEthnicity == null || localEthnicity < 0) return; if (localEthnicity == null || localEthnicity < 0) return;
const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source); const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source);
if (sourceEthnicity === localEthnicity) return; if (sourceEthnicity === localEthnicity) return;
@ -3558,7 +3428,7 @@ if (city.polityId === polity.id) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.
} }
seedFrontierPopulation(polity, tile) { seedFrontierPopulation(polity, tile) {
const source = this.nearestPolityTileConnection(polity, tile).source; const source = this.nearestPolityTileConnection(polity, tile).source;
const ethnicity = this.dominantCityEthnicity(source) ?? this.dominantTileEthnicity(tile); const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile];
if (!source || ethnicity == null || ethnicity < 0) return; if (!source || ethnicity == null || ethnicity < 0) return;
const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14))); const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14)));
source.population = Math.max(1, source.population - migrants); source.population = Math.max(1, source.population - migrants);
@ -3625,7 +3495,7 @@ if (share > 0) composition.set(id, Math.max(1, Math.round(basePopulation * share
} }
} }
if (!composition.size) { if (!composition.size) {
const regionalEthnicity = this.dominantTileEthnicity(targetTile); const regionalEthnicity = this.world.dominantEthnicity[targetTile];
const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1); const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1);
composition.set(sourceEthnicity, isFrontier ? 12 : 18); composition.set(sourceEthnicity, isFrontier ? 12 : 18);
} }
@ -3662,7 +3532,6 @@ this.cityBuckets.get(bucketKey).push(city);
this.addCityToPolity(city, polity, city.loyalty); this.addCityToPolity(city, polity, city.loyalty);
w.city[targetTile] = city.id; w.city[targetTile] = city.id;
this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65)); this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65));
this.assignNewCityToTerritoryOwner(city, targetTile);
this.projectCityPopulationToTiles(city); this.projectCityPopulationToTiles(city);
this.syncTilePopulationCulture(); this.syncTilePopulationCulture();
return city; return city;
@ -4131,8 +4000,6 @@ cleanupPolities() {
const survivors = []; const survivors = [];
for (const polity of this.polities) { for (const polity of this.polities) {
const cities = this.getPolityCities(polity); const cities = this.getPolityCities(polity);
polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length);
if ((polity.maxCityCount || 0) > 1) polity.everHadMultipleCities = true;
if (!cities.length) { if (!cities.length) {
this.markPolityEnded(polity, "collapsed"); this.markPolityEnded(polity, "collapsed");
continue; continue;
@ -4164,17 +4031,24 @@ const connectedCities = this.enforcePolityContinuity(polity);
if (connectedCities.length !== cities.length) { if (connectedCities.length !== cities.length) {
cities.length = 0; cities.length = 0;
cities.push(...connectedCities); cities.push(...connectedCities);
polity.maxCityCount = Math.max(polity.maxCityCount || 0, cities.length);
} }
if (cities.length === 1) { if (cities.length === 1) {
const singleCityFate = this.singleCityPolityCollapseReason(polity, cities[0]); const city = cities[0];
if (singleCityFate) { const ageYears = (this.year - (polity.founded ?? this.year)) / WEEKS_PER_YEAR;
this.markPolityEnded(polity, singleCityFate); const weakSingleCityState =
cities[0].polityId = null; ageYears > 80 &&
cities[0].loyalty = 0.45; city.population < 45 &&
cities[0].receivedAid = false; (polity.legitimacy ?? 0.7) < 0.25 &&
continue; (polity.cohesion ?? 0.6) < 0.25 &&
} (polity.crisis || 0) > 0.9;
if (weakSingleCityState) {
this.markPolityEnded(polity, "collapsed");
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
continue;
}
} }
survivors.push(polity); survivors.push(polity);
} }
@ -4189,20 +4063,6 @@ city.receivedAid = false;
} }
} }
} }
singleCityPolityCollapseReason(polity, city) {
if (!polity || !city) return "collapsed";
const age = this.polityAge(polity);
if (age < 80) return null;
const lowPopulation = (city.population || 0) < 18;
const lowResources = (city.storedResources || 0) < Math.max(1, (city.population || 1) * 0.025);
const lowInstitutions = (polity.legitimacy ?? 0.7) < 0.18 && (polity.cohesion ?? 0.6) < 0.18;
const highCrisis = (polity.crisis || 0) > 1.05;
const cityFailing = city.strength <= 0.12 || city.loyalty < 0.08;
if ((lowPopulation && (lowResources || cityFailing)) || (lowInstitutions && highCrisis && lowResources)) {
return polity.everHadMultipleCities ? "fragmented" : "collapsed";
}
return null;
}
updatePolities() { updatePolities() {
if (this.year % years(1) !== 0) return; if (this.year % years(1) !== 0) return;
this.cleanupPolities(); this.cleanupPolities();