457 lines
15 KiB
JavaScript
457 lines
15 KiB
JavaScript
// Data-driven content for civilisations, units, buildings, governments and
|
|
// technologies. The server and the browser both read these plain objects, so
|
|
// the catalogue text, costs and effects can never disagree between the two.
|
|
|
|
export const EFFECT_GDP = "gdp";
|
|
export const EFFECT_RESEARCH = "research";
|
|
export const EFFECT_CULTURE = "culture";
|
|
export const EFFECT_BUDGET_INCOME = "budget_income";
|
|
|
|
// Diplomatic relations between two civilisations. Only peace and war exist for
|
|
// now, but the catalogue is data-driven so further statuses (alliances,
|
|
// truces, ...) can be slotted in without touching the simulation.
|
|
export const RELATION_PEACE = "peace";
|
|
export const RELATION_WAR = "war";
|
|
export const RELATIONS = [RELATION_PEACE, RELATION_WAR];
|
|
|
|
// Kinds of news item shown in the feed. Newsworthy events are recorded in
|
|
// GameState with their in-game hour so every client can render them.
|
|
export const NEWS_WAR = "war";
|
|
export const NEWS_CAPTURE = "capture";
|
|
|
|
function effect(stat, baseValue, exponent = 1.0) {
|
|
return { stat, baseValue, exponent };
|
|
}
|
|
|
|
export function effectValue(e, level) {
|
|
if (level <= 0) return 0;
|
|
return e.baseValue * Math.pow(level, e.exponent);
|
|
}
|
|
|
|
export const CIVILISATIONS = [
|
|
{
|
|
id: "france",
|
|
name: "France",
|
|
demonym: "French",
|
|
primaryColour: "#0d0d5c",
|
|
accentColour: "#8c8c82",
|
|
flag: "flag_france.png",
|
|
cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"],
|
|
},
|
|
{
|
|
id: "britain",
|
|
name: "Britain",
|
|
demonym: "British",
|
|
primaryColour: "#a81818",
|
|
accentColour: "#0722ff",
|
|
flag: "flag_britain.png",
|
|
cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"],
|
|
},
|
|
{
|
|
id: "slovenia",
|
|
name: "Slovenia",
|
|
demonym: "Slovenian",
|
|
primaryColour: "#005da4",
|
|
accentColour: "#ed1c24",
|
|
flag: "flag_slovenia.png",
|
|
cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
|
|
},
|
|
{
|
|
id: "poland",
|
|
name: "Poland",
|
|
demonym: "Polish",
|
|
primaryColour: "#a6000b",
|
|
accentColour: "#ffffff",
|
|
flag: "flag_poland.png",
|
|
cityNames: ["Warsaw", "Kraków", "Łódź", "Wrocław", "Poznań", "Gdańsk", "Szczecin", "Bydgoszcz", "Lublin", "Katowice"],
|
|
},
|
|
{
|
|
id: "switzerland",
|
|
name: "Switzerland",
|
|
demonym: "Swiss",
|
|
primaryColour: "#da291c",
|
|
accentColour: "#ffffff",
|
|
flag: "flag_switzerland.png",
|
|
cityNames: ["Bern", "Zürich", "Geneva", "Basel", "Lausanne", "Lucerne", "Winterthur", "St. Gallen", "Lugano", "Biel"],
|
|
},
|
|
{
|
|
id: "ticino",
|
|
name: "Ticino",
|
|
demonym: "Ticinese",
|
|
primaryColour: "#4646e0",
|
|
accentColour: "#a6000b",
|
|
flag: "flag_ticino.png",
|
|
cityNames: ["Bellinzona", "Lugano", "Locarno", "Mendrisio", "Chiasso", "Airolo", "Biasca", "Ascona", "Faido", "Giubiasco"],
|
|
},
|
|
{
|
|
id: "galicia",
|
|
name: "Galicia",
|
|
demonym: "Galician",
|
|
primaryColour: "#006dad",
|
|
accentColour: "#ffffff",
|
|
flag: "flag_galicia.png",
|
|
cityNames: ["Santiago de Compostela", "Vigo", "A Coruña", "Ourense", "Lugo", "Pontevedra", "Ferrol", "Vilagarcía de Arousa", "Ribeira", "Redondela"],
|
|
},
|
|
{
|
|
id: "germany",
|
|
name: "Germany",
|
|
demonym: "German",
|
|
primaryColour: "#141414",
|
|
accentColour: "#ffce00",
|
|
flag: "flag_germany.png",
|
|
cityNames: ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt", "Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen"],
|
|
},
|
|
{
|
|
id: "iran",
|
|
name: "Iran",
|
|
demonym: "Iranian",
|
|
primaryColour: "#18a645",
|
|
accentColour: "#f01c21",
|
|
flag: "flag_iran.png",
|
|
cityNames: ["Tehran", "Mashhad", "Isfahan", "Karaj", "Shiraz", "Tabriz", "Qom", "Ahvaz", "Kermanshah", "Urmia"],
|
|
},
|
|
];
|
|
|
|
export const PROTO_UNITS = [
|
|
{
|
|
id: "modern_infantry",
|
|
name: "Modern infantry",
|
|
maxHp: 500,
|
|
cost: 100_000_000,
|
|
upkeep: 5000,
|
|
moveable: true,
|
|
speed: 0.5,
|
|
// Only military units fight, capture cities and may enter an enemy
|
|
// military unit's tile. `attack` is the damage dealt per combat round and
|
|
// `defense` is the damage absorbed per round; a defender's defense is
|
|
// doubled (the 100% defensive bonus). `range` is how many tiles away the
|
|
// unit can strike (1 is melee).
|
|
military: true,
|
|
attack: 100,
|
|
defense: 40,
|
|
range: 1,
|
|
traversableTerrains: ["Land"],
|
|
icon: "icon_modern_infantry.svg",
|
|
},
|
|
{
|
|
id: "worker",
|
|
name: "Worker",
|
|
maxHp: 100,
|
|
cost: 0,
|
|
upkeep: 0,
|
|
moveable: true,
|
|
speed: 0.5,
|
|
military: false,
|
|
attack: 0,
|
|
defense: 0,
|
|
range: 1,
|
|
traversableTerrains: ["Land"],
|
|
icon: "icon_settler.svg",
|
|
},
|
|
{
|
|
id: "artillery",
|
|
name: "Artillery",
|
|
maxHp: 400,
|
|
cost: 300_000_000,
|
|
upkeep: 12_000,
|
|
moveable: true,
|
|
speed: 0.25,
|
|
military: true,
|
|
attack: 250,
|
|
// Barely armoured: nearly every hit gets through.
|
|
defense: 5,
|
|
// Outranges infantry, so melee units cannot strike back from two tiles.
|
|
range: 2,
|
|
traversableTerrains: ["Land"],
|
|
icon: "icon_artillery.svg",
|
|
},
|
|
];
|
|
|
|
// Only units in this list can be trained from the city screen, matching
|
|
// GameState.TRAINABLE_UNITS.
|
|
export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
|
|
|
|
// Status effects a unit or city can carry. They are data-driven so new ones
|
|
// can be added without touching the simulation or the renderer: the server
|
|
// derives the active ids, applies their fields, and ships the ids in
|
|
// snapshots; the browser draws one icon per id. A status may define
|
|
// `hpLossPerDay` (a fraction of maximum hit points taken each day), a
|
|
// `cityHpLossPerDay` override for when the unit stands in a city, and an
|
|
// `upkeepMultiplier` applied to the unit's upkeep while it is active.
|
|
export const STATUS_ENCIRCLED = "encircled";
|
|
export const STATUS_BESIEGED = "besieged";
|
|
export const STATUS_BATTLE = "battle";
|
|
|
|
export const STATUSES = [
|
|
{
|
|
id: STATUS_ENCIRCLED,
|
|
label: "Encircled",
|
|
icon: "encirclement.svg",
|
|
description: "Cut off from every allied city while a hostile unit is adjacent. Loses 10% of its maximum hit points each day, or 2% inside a city.",
|
|
hpLossPerDay: 0.10,
|
|
cityHpLossPerDay: 0.02,
|
|
},
|
|
{
|
|
id: STATUS_BESIEGED,
|
|
label: "Besieged",
|
|
icon: "encirclement.svg",
|
|
description: "Cut off from every other allied city while a hostile unit is adjacent.",
|
|
},
|
|
{
|
|
id: STATUS_BATTLE,
|
|
label: "In battle",
|
|
icon: "icon_battle.svg",
|
|
description: "Fighting an enemy unit in range. Upkeep is tripled while the battle rages.",
|
|
upkeepMultiplier: 3,
|
|
},
|
|
];
|
|
|
|
export function statusById(id) {
|
|
return STATUSES.find((status) => status.id === id) || null;
|
|
}
|
|
|
|
export const BUILDINGS = [
|
|
{
|
|
id: "market",
|
|
name: "Market",
|
|
description: "A hub of local trade. Raises national GDP, with diminishing returns.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.01,
|
|
costAlpha: 1.6,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.00003,
|
|
upkeepAlpha: 1.5,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
|
|
},
|
|
{
|
|
id: "bank",
|
|
name: "Bank",
|
|
description: "Channels private wealth into the state coffers. Raises budget income.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.02,
|
|
costAlpha: 1.7,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.00004,
|
|
upkeepAlpha: 1.6,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
|
|
},
|
|
{
|
|
id: "research_lab",
|
|
name: "Research lab",
|
|
description: "Applies science to industry and government. Produces research points.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.015,
|
|
costAlpha: 1.65,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.00005,
|
|
upkeepAlpha: 1.55,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
|
|
},
|
|
{
|
|
id: "university",
|
|
name: "University",
|
|
description: "Higher education produces research and a little culture.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.025,
|
|
costAlpha: 1.7,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.00008,
|
|
upkeepAlpha: 1.6,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
|
|
},
|
|
{
|
|
id: "museum",
|
|
name: "Museum",
|
|
description: "Preserves national heritage, strengthening culture against foreign ideas.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.02,
|
|
costAlpha: 1.6,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.00005,
|
|
upkeepAlpha: 1.55,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
|
|
},
|
|
{
|
|
id: "cinema_studio",
|
|
name: "Cinema studio",
|
|
description: "Mass media spreads the nation's culture at home and abroad.",
|
|
maxLevel: 10,
|
|
baseCost: 0,
|
|
costGdpFraction: 0.03,
|
|
costAlpha: 1.75,
|
|
baseUpkeep: 0,
|
|
upkeepGdpFraction: 0.0001,
|
|
upkeepAlpha: 1.65,
|
|
refundFraction: 0.5,
|
|
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
|
|
},
|
|
];
|
|
|
|
export const GOVERNMENTS = [
|
|
{
|
|
id: "republic",
|
|
name: "Republic",
|
|
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
|
|
baseCost: 0,
|
|
costGdpFraction: 0.02,
|
|
effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
|
},
|
|
{
|
|
id: "monarchy",
|
|
name: "Monarchy",
|
|
description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.",
|
|
baseCost: 0,
|
|
costGdpFraction: 0.03,
|
|
effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
|
|
},
|
|
{
|
|
id: "technocracy",
|
|
name: "Technocracy",
|
|
description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.",
|
|
baseCost: 0,
|
|
costGdpFraction: 0.04,
|
|
effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)],
|
|
},
|
|
];
|
|
|
|
export const TECHNOLOGIES = [
|
|
{
|
|
id: "scientific_method",
|
|
name: "Scientific Method",
|
|
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
|
|
cost: 20,
|
|
prerequisites: [],
|
|
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
|
|
},
|
|
{
|
|
id: "industrial_automation",
|
|
name: "Industrial Automation",
|
|
description: "Robotics and control systems take over the factory floor, lifting national output.",
|
|
cost: 60,
|
|
prerequisites: ["scientific_method"],
|
|
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
|
|
},
|
|
{
|
|
id: "renewable_energy",
|
|
name: "Renewable Energy",
|
|
description: "Clean generation cuts waste and strengthens the public finances.",
|
|
cost: 80,
|
|
prerequisites: ["scientific_method"],
|
|
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
|
|
},
|
|
{
|
|
id: "quantum_computing",
|
|
name: "Quantum Computing",
|
|
description: "Machines that reason in superposition. A step change in the pace of discovery.",
|
|
cost: 120,
|
|
prerequisites: ["scientific_method"],
|
|
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
|
|
},
|
|
{
|
|
id: "space_program",
|
|
name: "Space Program",
|
|
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
|
|
cost: 300,
|
|
prerequisites: ["industrial_automation", "quantum_computing"],
|
|
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
|
|
},
|
|
];
|
|
|
|
export const ECONOMY = {
|
|
baseGdpPerCapita: 10_000,
|
|
basePopulation: 100_000,
|
|
cityTileGdpMultiplier: 2.0,
|
|
capitalTileGdpMultiplier: 2.5,
|
|
citySurroundingGdpBonus: 0.3,
|
|
gdpPerProductionPoint: 100_000,
|
|
budgetGdpRate: 0.001,
|
|
startingBudget: 0,
|
|
basePopulationGrowthRate: 0.005,
|
|
|
|
productionCapacity(gdp) {
|
|
if (this.gdpPerProductionPoint <= 0) return 0;
|
|
return gdp / this.gdpPerProductionPoint;
|
|
},
|
|
|
|
trainingHours(gdp, cost) {
|
|
const capacity = this.productionCapacity(gdp);
|
|
if (capacity <= 0) return 0;
|
|
return cost / capacity;
|
|
},
|
|
|
|
budgetIncome(gdp, hours = 1) {
|
|
return gdp * this.budgetGdpRate * hours;
|
|
},
|
|
|
|
populationGrowthFactor(hoursPerYear, hours = 1) {
|
|
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
|
|
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
|
|
},
|
|
};
|
|
|
|
export const MAP_CONFIG = {
|
|
mapSize: { x: 64, y: 40 },
|
|
topology: "cylindrical",
|
|
terrainSeed: 0,
|
|
seaLevel: -0.2,
|
|
falloffEnabled: true,
|
|
falloffStart: 0.55,
|
|
falloffStrength: 0.6,
|
|
iceLatitude: 0.9,
|
|
noise: {
|
|
frequency: 0.06,
|
|
octaves: 4,
|
|
lacunarity: 2.0,
|
|
gain: 0.5,
|
|
},
|
|
capitalMargin: 2,
|
|
minCapitalDistance: 8,
|
|
minCapitalLandmass: 40,
|
|
citiesPerCiv: 10,
|
|
citySpacing: 8,
|
|
};
|
|
|
|
// The terrain TileSet, flattened. `col` is the atlas column in terrain.png
|
|
// (one row, 7 tiles of 63x56); the class decides land/sea/ice and the
|
|
// minElevation bands place land tiles. `defenseBonus` is the fraction added to
|
|
// a unit's defense while it stands on the tile.
|
|
export const TERRAIN_TILES = [
|
|
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
|
|
{ col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
|
|
{ col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
|
|
{ col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
|
|
{ col: 4, terrainType: "Land", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
|
|
{ col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
|
|
{ col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
|
|
];
|
|
|
|
// A city standing on a tile adds this fraction on top of the terrain's own
|
|
// defensive bonus.
|
|
export const CITY_DEFENSE_BONUS = 0.5;
|
|
|
|
export function protoUnitById(id) {
|
|
return PROTO_UNITS.find((u) => u.id === id) || null;
|
|
}
|
|
|
|
export function buildingById(id) {
|
|
return BUILDINGS.find((b) => b.id === id) || null;
|
|
}
|
|
|
|
export function governmentById(id) {
|
|
return GOVERNMENTS.find((g) => g.id === id) || null;
|
|
}
|
|
|
|
export function technologyById(id) {
|
|
return TECHNOLOGIES.find((t) => t.id === id) || null;
|
|
}
|