258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
// Politics tuning: public opinion, approval penalties, propaganda campaigns,
|
|
// minority policies, migration, war crimes, uprisings and cultural impact.
|
|
//
|
|
// Every population (an ethnicity, indexed like the civilisations) holds an
|
|
// opinion of every other ethnicity and of every government. Opinions run from 0
|
|
// (hatred) to 1 (devotion) and start at the neutral 50%. All of it lives in
|
|
// shared/game_state/politics.js so the server simulates it and the browser only
|
|
// reads it from the snapshot.
|
|
|
|
// The opinion scale. `driftPerDay` pulls every opinion back toward the neutral
|
|
// baseline; at this rate the full 0..1 span recovers over roughly 45 years, so
|
|
// a campaign's goodwill lingers for years while a war crime is not forgotten
|
|
// for a generation.
|
|
export const OPINION = {
|
|
baseline: 0.5,
|
|
driftPerDay: 1.0 / (365.25 * 45),
|
|
min: 0.0,
|
|
max: 1.0,
|
|
};
|
|
|
|
// A region whose people approve of their government by less than
|
|
// `rebellionThreshold` suffers the penalties below. Construction slows
|
|
// exponentially as approval falls and the tax take collapses toward
|
|
// `taxFloorFraction` of its normal value.
|
|
export const APPROVAL = {
|
|
rebellionThreshold: 0.1,
|
|
constructionExponent: 2.5,
|
|
taxFloorFraction: 0.1,
|
|
};
|
|
|
|
// Popularity is counted in integer points, centred on 0 (neutral). The whole
|
|
// 0..1 opinion scale maps to -100..+100 points, one point being half a percent.
|
|
// What the player sees is a logistic of those points: 0 points reads 50%, +100
|
|
// reads 75% and -100 reads 25%. Opinions drift one point an hour toward
|
|
// neutrality, and war, captures and casualties move them in points.
|
|
export const POPULARITY = {
|
|
opinionPerPoint: 0.005,
|
|
// Steepness chosen so display(0) = 0.5 and display(100) = 0.75.
|
|
logisticScale: Math.log(3) / 100,
|
|
// Points a population's opinion of its government moves toward neutral each
|
|
// hour, in either direction.
|
|
driftPerHour: 1,
|
|
// Declaring war costs the aggressor this many points with every population.
|
|
declareWar: 8,
|
|
// Being declared on grants the victim this many points with its own people,
|
|
// but only when the aggressor was already loathed there (below threshold).
|
|
declaredOnBoost: 15,
|
|
declaredOnThreshold: 0.15,
|
|
// Points won or lost per military unit destroyed, and per city taken.
|
|
unitLoss: 1,
|
|
unitKill: 1,
|
|
cityLoss: 10,
|
|
cityCapture: 10,
|
|
// Pillaging a tile costs the aggressor standing with the whole world, doubled
|
|
// among the populations directly affected.
|
|
pillage: 6,
|
|
};
|
|
|
|
// An opinion shift as the integer popularity points the policies are quoted in,
|
|
// so the wizard's estimates come straight from the applied effect.
|
|
export function opinionToPoints(opinion) {
|
|
return Math.round(opinion / POPULARITY.opinionPerPoint);
|
|
}
|
|
|
|
// A signed popularity figure with the typographic minus the catalogue uses.
|
|
function popularityPointsText(opinion) {
|
|
const points = opinionToPoints(opinion);
|
|
return `${points < 0 ? "\u2212" : "+"}${Math.abs(points)}`;
|
|
}
|
|
|
|
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
|
|
// as it runs, like any other upkeep and shown in the budget. The player may
|
|
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
|
|
// with it. A campaign stays in force until cancelled. `dailyDelta` is the
|
|
// opinion shift per day at the default spend and balanced cultural impact;
|
|
// `culturalWeight` is how strongly the two nations' cultural impacts bend the
|
|
// result.
|
|
//
|
|
// Convincing the last few points is the hardest: the daily shift is scaled by
|
|
// how much room is left before the opinion reaches its maximum (warming) or
|
|
// minimum (cooling), raised to `difficultyExponent`. The rate is unchanged at
|
|
// or below the neutral midpoint, so only the final approach to a devoted or
|
|
// hateful population suffers the diminishing returns that make the last
|
|
// percentage points disproportionately expensive.
|
|
//
|
|
// Opinions otherwise drift one point an hour toward neutral (0.12 a day), so a
|
|
// campaign must out-spend that drift to change minds; `dailyDelta` is set well
|
|
// above it.
|
|
export const PROPAGANDA = {
|
|
defaultHourlyCost: 8_000,
|
|
maxHourlyCost: 40_000,
|
|
dailyDelta: 0.18,
|
|
culturalWeight: 0.6,
|
|
difficultyExponent: 2,
|
|
};
|
|
|
|
export const CAMPAIGN_ETHNICITY = "ethnicity";
|
|
export const CAMPAIGN_GOVERNMENT = "government";
|
|
|
|
// Minority policies a government may proclaim. Any number may be active at
|
|
// once, but the same policy may not be enacted twice for the same ethnicity.
|
|
// `targetEffect` and `politicalCost` are the estimates the wizard shows; the
|
|
// model applies them through POLICY_OPINION below. Effects are stated in
|
|
// popularity points (the integer unit the model works in); only the final
|
|
// approval figure is ever shown as a percentage.
|
|
export const POLICY_CHAMPION = "champion";
|
|
export const POLICY_EXPEL = "expel";
|
|
export const POLICY_PROTECT = "protect";
|
|
export const POLICY_IMMIGRATION = "immigration";
|
|
|
|
export const POLICIES = [
|
|
{
|
|
id: POLICY_CHAMPION,
|
|
name: "Champion a minority",
|
|
description:
|
|
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
|
|
get targetEffect() {
|
|
return `${popularityPointsText(POLICY_OPINION.championTarget)} popularity among the chosen people`;
|
|
},
|
|
get politicalCost() {
|
|
return `${popularityPointsText(POLICY_OPINION.championMajority)} popularity among every other population in your country`;
|
|
},
|
|
},
|
|
{
|
|
id: POLICY_PROTECT,
|
|
name: "Protect a minority",
|
|
description:
|
|
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
|
|
get targetEffect() {
|
|
return `${popularityPointsText(POLICY_OPINION.protectTarget)} popularity among the chosen people`;
|
|
},
|
|
get politicalCost() {
|
|
return `${popularityPointsText(POLICY_OPINION.protectMajority)} popularity among every other population in your country`;
|
|
},
|
|
},
|
|
{
|
|
id: POLICY_EXPEL,
|
|
name: "Expel a minority",
|
|
description:
|
|
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
|
|
targetEffect: "The people begin to leave for neighbouring countries",
|
|
get politicalCost() {
|
|
return `${popularityPointsText(POLICY_OPINION.expelTarget)} popularity among the expelled people, remembered for a generation`;
|
|
},
|
|
},
|
|
{
|
|
id: POLICY_IMMIGRATION,
|
|
name: "Encourage immigration",
|
|
description:
|
|
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
|
|
targetEffect: "The chosen people migrate into your country",
|
|
get politicalCost() {
|
|
return `${popularityPointsText(POLICY_OPINION.immigrationOthers)} popularity among every other population in your country`;
|
|
},
|
|
},
|
|
];
|
|
|
|
export function policyById(id) {
|
|
return POLICIES.find((policy) => policy.id === id) || null;
|
|
}
|
|
|
|
// Immediate opinion shifts of proclaiming each policy, addressed to the target
|
|
// ethnicity and to the rest of the nation's people.
|
|
export const POLICY_OPINION = {
|
|
championTarget: 0.15,
|
|
championMajority: -0.08,
|
|
protectTarget: 0.12,
|
|
protectMajority: -0.04,
|
|
expelTarget: -0.4,
|
|
immigrationOthers: -0.05,
|
|
};
|
|
|
|
// Daily share of a minority that is expelled to neighbouring tiles, and the
|
|
// rate at which an encouraged ethnicity is drawn into the country. Immigration
|
|
// is scaled by how much the newcomer dislikes their current government:
|
|
// a 5% approval government sheds people fast, a 90% one holds them.
|
|
export const MIGRATION = {
|
|
expelSharePerDay: 0.004,
|
|
immigrationSharePerDay: 0.002,
|
|
// People fly from any other airport city into a city with an airport. The
|
|
// share is per destination airport level, per source city, per day.
|
|
airportSharePerDay: 0.0005,
|
|
// The approval at or above which nobody is inclined to leave.
|
|
comfortableApproval: 0.9,
|
|
|
|
// People move only to a region at least this much richer per head, and the
|
|
// bar rises with the travel time: a destination `time` hours away must be
|
|
// `incomeRatioBase * (1 + time * incomeTimePenalty)` times as rich. So the
|
|
// closer and richer a place is, the more attractive.
|
|
incomeRatioBase: 2,
|
|
incomeTimePenalty: 0.02,
|
|
// People travel the transport network; a tile of open terrain costs this many
|
|
// hours at walking pace, a road or railway less, open water a flat sea rate.
|
|
peopleSpeed: 1,
|
|
seaHoursPerTile: 1.5,
|
|
// Land routes are only traced this far; beyond it a destination is reachable
|
|
// only by air.
|
|
maxTravelHours: 120,
|
|
// Of the people who qualify, this share of the poorer region moves per day,
|
|
// scaled by how far the ratio clears the bar and capped.
|
|
incomeSharePerDay: 0.0015,
|
|
incomeGapCap: 1,
|
|
// No single day moves more than this share of a region's people by land.
|
|
incomeMaxSharePerDay: 0.005,
|
|
// An airport flies people over the distance at a flat time cost, but moves at
|
|
// most this many people per day for each airport level on the route.
|
|
airHours: 6,
|
|
airPeoplePerDayPerLevel: 1000,
|
|
// No tile loses more than this share of its people to migration in one day,
|
|
// whichever mechanism carries them.
|
|
maxDailyOutShare: 0.0001,
|
|
|
|
// At generation, regions of a nation trade people until their production per
|
|
// head is level. `equalizeTolerance` is the residual spread accepted and
|
|
// `equalizePasses` bounds the refinement; no region is emptied below
|
|
// `minRegionPopulation`.
|
|
equalizeTolerance: 0.005,
|
|
equalizePasses: 30,
|
|
minRegionPopulation: 20000,
|
|
};
|
|
|
|
// A pillaged tile is a war crime. The people of the tile loathe the aggressor
|
|
// immediately; the stain spreads to neighbours, the region, the victim nation
|
|
// and the watching world, and is remembered for a generation.
|
|
export const WAR_CRIME = {
|
|
tileOpinion: 0.35,
|
|
tileEthnicOpinion: 0.2,
|
|
neighbourFactor: 0.5,
|
|
regionalFactor: 0.3,
|
|
nationalFactor: 0.2,
|
|
worldFactor: 0.08,
|
|
memoryYears: 25,
|
|
};
|
|
|
|
// A mistreated minority rises up when it hates its government and adores the
|
|
// one that shares its ethnicity, taking up arms for the latter.
|
|
export const REBELLION = {
|
|
approvalMax: 0.1,
|
|
championApprovalMin: 0.7,
|
|
populationShareMin: 0.12,
|
|
cooldownDays: 60,
|
|
maxUnitsPerEthnicity: 8,
|
|
};
|
|
|
|
// Declaring war on a government its people like costs the aggressor dear; a war
|
|
// on a loathed government can even be popular. The penalty scales with how far
|
|
// the onlookers' opinion of the victim strays from neutral.
|
|
export const WAR_DECLARATION = {
|
|
penalty: 0.3,
|
|
};
|
|
|
|
// Cultural impact is a nation's share of the world's cultural output, so it is
|
|
// zero-sum by construction. A nation above the average impact slowly wins the
|
|
// world over to its government.
|
|
export const CULTURE_IMPACT = {
|
|
dailyPopularityGain: 0.0004,
|
|
};
|