Added warfare depth, focused research and a reworked HUD

- Tile improvements (fortifications, radar towers, coastal cannons), trenches,
  zone of control, support-unit capture and launch transports, with parabolic
  barrage arcs drawn between attacker and target.
- Focused research that pours points straight into the chosen technology, with
  per-technology progress kept when the focus changes, plus repeatable levels.
- Popularity reworked to integer points, war and pillage effects, minority
  policies and month-over-month arrows on the nation summary.
- City progress bars, square improvement buttons, a technology tab of wide
  short cards with progress bars, transport cargo readout and double-click to
  open a city panel.
This commit is contained in:
2026-09-21 01:07:36 +02:00
parent 914bfee6ab
commit ede258d39f
62 changed files with 3842 additions and 463 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ export const BUILDINGS = [
id: "market",
name: "Market",
description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.",
baseCost: 5_000_000,
baseCost: 500_000,
costAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
@@ -19,7 +19,7 @@ export const BUILDINGS = [
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises the tax collected from the city's region.",
baseCost: 10_000_000,
baseCost: 1_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
+17
View File
@@ -14,6 +14,8 @@ export const BATTLE = {
gdpPerCapitaLossPerDay: 0.3,
recoveryDelayDays: 30,
recoveryPerDay: 100,
// Structures on a battlefield take this much damage each day of fighting.
structureDamagePerDay: 120,
};
// Pillaging: each unit removes this fraction of the tile's remaining
@@ -25,6 +27,15 @@ export const PILLAGE = {
healPerPeople: 1000,
};
// Trenches: infantry dug into a tile are much harder to dislodge, at the cost
// of the local economy. The defense figures are multipliers on a unit's base
// defense; melee attackers get no bonus, artillery is heavily blunted.
export const TRENCH = {
gdpMultiplier: 0.8,
meleeDefenseMultiplier: 2.0,
artilleryDefenseMultiplier: 3.0,
};
// Sustained bombardment: a ground or naval battery ordered onto a target fires
// one volley every in-game hour. Each volley deals this fraction of the damage
// a single strike would (attack minus the defender's effective defense), so a
@@ -33,6 +44,12 @@ export const BOMBARD = {
hourlyDamageFraction: 0.1,
};
// Bombarding a city that has no garrison left: each point of damage kills this
// many people and leaves the usual battle scar on its tile.
export const CITY_BOMBARD = {
populationPerDamage: 10,
};
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days).
// Each attacking aircraft costs `cost` from its nation's budget, charged up
+7
View File
@@ -6,6 +6,13 @@ export const EFFECT_GDP = "gdp";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
// Repeatable technology effects. Each is a fraction that stacks with every
// further level researched.
export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
export const EFFECT_INFANTRY_DEFENSE = "infantry_defense";
export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
export const EFFECT_RAIL_SPEED = "rail_speed";
export function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
+56
View File
@@ -15,6 +15,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.5,
buildCost: 50_000_000,
maintenance: 100_000,
icon: "icon_road.svg",
},
{
id: "railway",
@@ -23,6 +24,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.25,
buildCost: 150_000_000,
maintenance: 300_000,
icon: "icon_railway.svg",
},
];
@@ -33,3 +35,57 @@ export const TRANSPORT_BY_ID = Object.fromEntries(
export function transportImprovement(id) {
return TRANSPORT_BY_ID[id] || null;
}
// Player-buildable military tile improvements. Unlike transport, these take
// construction time (the same GDP-scaled formula as buildings and units) and
// cost an hourly maintenance. A tile may carry one of these plus a road or
// railway. `defense` is the extra fraction of base defense granted to units
// stationed there against melee and artillery fire.
export const TILE_IMPROVEMENTS = [
{
id: "fortifications",
name: "Fortifications",
description:
"Earthworks and bunkers. Holds a permanent area of control, shelters its garrison and takes damage in combat.",
buildCost: 200_000_000,
maintenance: 10_000,
vision: 2,
defense: { melee: 2.0, artillery: 4.0 },
permanentControl: true,
takesCombatDamage: true,
icon: "icon_fortifications.svg",
},
{
id: "radar_tower",
name: "Radar tower",
description:
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
buildCost: 60_000_000,
maintenance: 1000,
vision: 5,
takesRangedDamage: true,
capturable: true,
icon: "icon_radar_tower.svg",
},
{
id: "coastal_cannon",
name: "Coastal cannon",
description:
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
buildCost: 120_000_000,
maintenance: 2000,
range: 2,
automatic: true,
takesDamage: true,
coastal: true,
icon: "icon_coastal_cannon.svg",
},
];
export const TILE_IMPROVEMENT_BY_ID = Object.fromEntries(
TILE_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
);
export function tileImprovementById(id) {
return TILE_IMPROVEMENT_BY_ID[id] || null;
}
+43 -9
View File
@@ -28,6 +28,34 @@ export const APPROVAL = {
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,
};
// 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
@@ -36,16 +64,20 @@ export const APPROVAL = {
// `culturalWeight` is how strongly the two nations' cultural impacts bend the
// result.
//
// Convincing the last few percent is the hardest: the daily shift is scaled by
// 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.012,
dailyDelta: 0.18,
culturalWeight: 0.6,
difficultyExponent: 2,
};
@@ -58,7 +90,9 @@ export const CAMPAIGN_COOL = -1;
// 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.
// 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";
@@ -70,16 +104,16 @@ export const POLICIES = [
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.",
targetEffect: "+15% approval among the chosen people",
politicalCost: "−8% approval among every other population in your country",
targetEffect: "+30 popularity among the chosen people",
politicalCost: "−16 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.",
targetEffect: "+12% approval among the chosen people",
politicalCost: "−4% approval among every other population in your country",
targetEffect: "+24 popularity among the chosen people",
politicalCost: "−8 popularity among every other population in your country",
},
{
id: POLICY_EXPEL,
@@ -87,7 +121,7 @@ export const POLICIES = [
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",
politicalCost: "−40% approval among the expelled people, remembered for a generation",
politicalCost: "−80 popularity among the expelled people, remembered for a generation",
},
{
id: POLICY_IMMIGRATION,
@@ -95,7 +129,7 @@ export const POLICIES = [
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",
politicalCost: "−5% approval among every other population in your country",
politicalCost: "−10 popularity among every other population in your country",
},
];
+3
View File
@@ -11,5 +11,8 @@ export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
// A government enacted a minority policy. Recorded by the model.
export const NEWS_POLICY = "policy";
// A technology was discovered. Repeatable research levels raise these too, but
// flagged so the news list can keep them quiet while the feed still notifies.
export const NEWS_TECHNOLOGY = "technology";
// A player took control of a nation. Recorded by the server, not the model.
export const NEWS_JOIN = "join";
+97 -1
View File
@@ -1,13 +1,39 @@
// Research tree. A technology costs research points and may require earlier
// technologies before it can be started.
//
// Research is focused: a nation picks one technology, and every research point
// it creates is applied to it, month on month, until it is discovered. A
// `repeatable` technology may be researched again and again, each level costing
// `repeatCostGrowth` more than the last, and unlike a discovery it raises no
// news event (it only notifies the player).
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
import {
EFFECT_GDP,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
effect,
} from "./effects.js";
// The tabs the technology panel groups research under.
export const TECH_THEMES = [
{ id: "science", name: "Science" },
{ id: "economy", name: "Economy" },
{ id: "society", name: "Society" },
{ id: "military", name: "Military" },
];
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
theme: "science",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
@@ -16,6 +42,7 @@ export const TECHNOLOGIES = [
id: "industrial_automation",
name: "Industrial Automation",
description: "Robotics and control systems take over the factory floor, lifting national output.",
theme: "economy",
cost: 60,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
@@ -24,6 +51,7 @@ export const TECHNOLOGIES = [
id: "renewable_energy",
name: "Renewable Energy",
description: "Clean generation cuts waste and strengthens the public finances.",
theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
@@ -32,6 +60,7 @@ export const TECHNOLOGIES = [
id: "quantum_computing",
name: "Quantum Computing",
description: "Machines that reason in superposition. A step change in the pace of discovery.",
theme: "science",
cost: 120,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
@@ -40,12 +69,79 @@ export const TECHNOLOGIES = [
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
theme: "science",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
},
// Repeatable levels. Each raises a single stat by 1% of its base value.
{
id: "infantry_weapons",
name: "Infantry Weapons",
description: "Better small arms and training raise the damage infantry deal.",
theme: "military",
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
},
{
id: "body_armour",
name: "Body Armour",
description: "Personal protection raises infantry base defense.",
theme: "military",
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
},
{
id: "artillery_shells",
name: "Artillery Shells",
description: "Improved propellant and shells raise the damage artillery deal.",
theme: "military",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
},
{
id: "construction_technology",
name: "Construction Technology",
description: "Better methods and machines speed every building and unit the nation produces.",
theme: "economy",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
},
{
id: "trains_technology",
name: "Trains Technology",
description: "Faster locomotives raise movement speed along railways.",
theme: "economy",
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
},
];
export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
// The cost of the next level of a technology given how many times it has been
// researched already. Non-repeatable technologies cost the same either way.
export function technologyCost(proto, repeatCount = 0) {
if (!proto) return 0;
if (!proto.repeatable) return proto.cost;
const growth = proto.repeatCostGrowth || 0;
return proto.cost * Math.pow(1 + growth, Math.max(0, repeatCount));
}
+48
View File
@@ -14,6 +14,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.5,
military: true,
// Marks the unit for the infantry research effects (weapons, body armour).
infantry: true,
attack: 100,
defense: 40,
range: 1,
@@ -44,6 +46,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.25,
military: true,
// Marks the unit for the artillery research effect.
artillery: true,
attack: 250,
// Barely armoured: nearly every hit gets through.
defense: 5,
@@ -109,10 +113,52 @@ export const PROTO_UNITS = [
attack: 400,
defense: 120,
range: 2,
naval: true,
traversableTerrains: ["Sea"],
requiresBuilding: "shipyard",
icon: "icon_battleship.svg",
},
{
id: "mobile_radar",
name: "Mobile radar",
maxHp: 200,
cost: 50_000_000,
upkeep: 5000,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 0,
// Watches four tiles out, cannot fight, is taken by any unit that walks
// onto it, and a lone one is destroyed outright by a single ranged hit.
vision: 4,
capturable: true,
destroyedByRangedAlone: true,
traversableTerrains: ["Land"],
icon: "icon_radar.svg",
},
{
id: "launch",
name: "Launch",
maxHp: 150,
cost: 20_000_000,
upkeep: 2000,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 0,
// Transports one infantry unit and is defenceless on its own.
transportCapacity: 1,
transportKind: "infantry",
capturable: true,
naval: true,
traversableTerrains: ["Sea"],
requiresBuilding: "port",
icon: "icon_launch.svg",
},
];
// Only units in this list can be trained from the city screen.
@@ -122,6 +168,8 @@ export const TRAINABLE_UNIT_IDS = [
"jet_fighter",
"bomber",
"battleship",
"mobile_radar",
"launch",
];
export function protoUnitById(id) {
+41 -3
View File
@@ -44,6 +44,8 @@ import { politicsMethods } from "./game_state/politics.js";
import { orderMethods } from "./game_state/orders.js";
import { visibilityMethods } from "./game_state/visibility.js";
import { serializationMethods } from "./game_state/serialization.js";
import { monthlyMethods } from "./game_state/monthly.js";
import { warfareMethods } from "./game_state/warfare.js";
export {
MAX_NEWS,
@@ -104,6 +106,15 @@ export class GameState {
this.roads = new Set();
// Tiles carrying a railway. A tile holds a road or a railway, never both.
this.railways = new Set();
// War-time tile works: trenches dug by infantry, and military improvements
// (fortifications, radar towers, coastal cannons) with their owner and
// remaining hit points. A tile works queue per civilisation advances them.
this.trenches = new Set();
this.tileImprovements = new Map();
this.tileImprovementOwner = new Map();
this.tileImprovementHp = new Map();
this.tileWorks = new Map();
this._tileImprovementVersion = 0;
// Bumped whenever the transport network changes, so the serialised road and
// railway lists are rebuilt only when something actually moved.
this._improvementVersion = 0;
@@ -116,10 +127,18 @@ export class GameState {
this.tileBattleRecoveryStart = new Map();
this.budgets = new Map();
this.training = new Map();
this.research = new Map();
this.culture = new Map();
this.government = new Map();
this.researched = new Map();
// Focused research: each nation works on one technology at a time and every
// point it creates flows straight into that technology. There is no bank: a
// nation with no focus simply does not advance. `techProgress` keeps the
// points committed to every technology the nation has started, so switching
// focus never loses work already done, and `repeatCounts` counts how many
// times each repeatable technology has been researched.
this.focusTechnology = new Map();
this.techProgress = new Map();
this.repeatCounts = new Map();
this.explored = new Map();
this.visible = new Map();
// Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
@@ -176,6 +195,9 @@ export class GameState {
this._cityAdjacent = new Map();
this._territoryByCiv = new Map();
this._gdpPerCapitaCache = new Map();
// Memoised nation-wide modifier totals, invalidated by _touchModifiers.
this._civModifierCache = new Map();
this._modifiersVersion = 0;
// Per-civ factors that map generation picked so every nation starts with
// the same population and GDP per capita (see _balanceStartingEconomy).
this._populationBalance = new Map();
@@ -184,6 +206,11 @@ export class GameState {
this._serializedExplored = new Map();
this._neighbourCache = new Map();
this._visibilityDirty = true;
// Month-over-month baseline for the nation summary bar; null until the
// world exists and January 2000 has a month to compare against.
this.monthlyStats = null;
this._monthIndexNow = 0;
this._currentMonthStats = null;
}
onChanged(fn) {
@@ -205,10 +232,12 @@ export class GameState {
this._resetCollections();
for (let i = 0; i < this.civilisations.length; i++) {
this.research.set(i, 0);
this.culture.set(i, 0);
this.government.set(i, 0);
this.researched.set(i, new Set());
this.focusTechnology.set(i, -1);
this.techProgress.set(i, new Map());
this.repeatCounts.set(i, new Map());
}
this.totalHours = 0;
this._configured = true;
@@ -217,6 +246,7 @@ export class GameState {
this._refreshStatuses();
this._revealAll();
this._refreshVisibility();
this._initMonthly();
this._visibilityDirty = false;
this._emitChanged();
}
@@ -267,10 +297,13 @@ export class GameState {
tickHour() {
this.totalHours += 1;
this._tickMonthly();
this._tickTraining();
this._tickAirUnits();
this._tickStrikes();
this._tickGarrisonHealing();
// Popularity drifts toward neutral every hour, not just daily.
this._tickOpinionDrift();
if (this._visibilityDirty) {
this._refreshVisibility();
this._visibilityDirty = false;
@@ -286,6 +319,9 @@ export class GameState {
this._resolveCombatRound();
}
this._tickBuildings();
this._tickTileWorks();
this._tickEmbark();
this._tickCoastalCannons();
// A unit no longer sharing a tile with an enemy is not an invader any more.
for (const unit of this.units) {
if (unit.invader && !this._hasHostileOn(unit.coords, unit.civ)) unit.invader = false;
@@ -309,5 +345,7 @@ Object.assign(
politicsMethods,
orderMethods,
visibilityMethods,
serializationMethods
serializationMethods,
monthlyMethods,
warfareMethods
);
+92 -25
View File
@@ -3,7 +3,7 @@
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
import { AIR_STRIKE, BOMBARD } from "../data/combat.js";
import { AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
@@ -164,14 +164,77 @@ export const airMethods = {
// ----------------------------------------------------------- strike --
_strikeTargetAt(coords, civ) {
const enemies = this.unitsAt(coords).filter(
(unit) => unit.civ !== civ && this._isMilitary(unit)
);
const enemies = this.unitsAt(coords).filter((unit) => {
if (unit.civ === civ) return false;
// Support units (mobile radar, launches) are legitimate targets for
// ranged fire even though they never fight back.
const proto = this.unitProto(unit);
return this._isMilitary(unit) || (proto && proto.capturable);
});
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
// Infantry shelters the rest of a stack, so exposed support units are hit
// before dug-in troops.
enemies.sort((a, b) => {
const aExposed = this._isExposedTarget(a) ? 0 : 1;
const bExposed = this._isExposedTarget(b) ? 0 : 1;
return aExposed - bExposed || (a.hp - b.hp) || (a.id - b.id);
});
return enemies[0];
},
_isExposedTarget(unit) {
const proto = this.unitProto(unit);
return !!(proto && (proto.capturable || proto.artillery));
},
// An enemy city at the tile, the target of a bombardment when its garrison
// has already fallen.
_strikeCityAt(coords, civ) {
const city = this.cityAt(coords);
if (!city || city.civ === civ) return null;
return city;
},
// Ranged fire on a city with no garrison left: it kills people and leaves the
// usual battle scar, and the tile's structures take a share of the blow.
_bombardCity(city, amount) {
const k = key(city.coords.x, city.coords.y);
const population = this.tilePopulation.get(k) || 0;
const removed = Math.min(population, amount * CITY_BOMBARD.populationPerDamage);
if (removed > 0) {
this.tilePopulation.set(k, population - removed);
this._populationVersion += 1;
}
const perCapita = this.getTileGdpPerCapita(city.coords);
if (perCapita > 0) {
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + perCapita * 0.1;
this.tileBattleGdpDeficit.set(k, deficit);
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
this._gdpPerCapitaCache.delete(k);
}
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
this._emitChanged();
},
// Applies one ranged blow to a target unit, with the special fates of exposed
// support units, and passes a tenth of the damage on to any structures on the
// tile.
_applyStrikeDamage(target, damage, attackerProto = null) {
const proto = this.unitProto(target);
const alone = this.unitsAt(target.coords).length <= 1;
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
else if (proto && proto.naval && proto.capturable && alone) damage = target.hp;
target.hp -= damage;
// The tile's structures always take a tenth of an artillery or aerial blow.
this._damageTileImprovement(target.coords, Math.max(1, Math.round(damage * 0.1)));
if (target.hp > 0) return false;
target.hp = 0;
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
return true;
},
// How far a unit can reach: an aircraft's mission radius, a ground battery's
// weapon range.
_strikeRange(proto) {
@@ -226,9 +289,10 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
const city = target ? null : this._strikeCityAt(goal, civ);
if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
this._declareWar(civ, target.civ);
this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) {
this._stopUnit(unit);
unit.strikeTarget = { x: goal.x, y: goal.y };
@@ -245,7 +309,8 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
const city = target ? null : this._strikeCityAt(goal, civ);
if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
// `_strikeReady` above already proved the round trip fits the fuel; this
@@ -258,7 +323,7 @@ export const airMethods = {
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
this._declareWar(civ, target.civ);
this._declareWar(civ, target ? target.civ : city.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
unit.pathIndex = 0;
@@ -283,12 +348,11 @@ export const airMethods = {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
target.hp -= damage;
if (target.hp <= 0) {
target.hp = 0;
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
this._applyStrikeDamage(target, damage, this.unitProto(unit));
} else {
const city = this._strikeCityAt(goal, unit.civ);
if (city) {
this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
}
}
}
@@ -305,26 +369,29 @@ export const airMethods = {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
if (!target || !this._strikeReady(unit, goal)) {
if (!target) {
// No garrison left: the battery shells the city itself.
const city = this._strikeCityAt(goal, unit.civ);
if (city && this._strikeReady(unit, goal)) {
this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
} else {
unit.strikeTarget = null;
}
continue;
}
if (!this._strikeReady(unit, goal)) {
unit.strikeTarget = null;
continue;
}
const full = this._unitAttack(unit) - this._effectiveDefense(target, true);
const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [target, amount] of damage) {
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
destroyed.push(target);
}
if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target);
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
+108 -9
View File
@@ -3,6 +3,12 @@
import { key } from "../hex.js";
import { BATTLE } from "../data/combat.js";
import { POPULARITY } from "../data/politics.js";
import {
EFFECT_INFANTRY_ATTACK,
EFFECT_INFANTRY_DEFENSE,
EFFECT_ARTILLERY_ATTACK,
} from "../data/effects.js";
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
import { defenseMultiplier } from "../rules.js";
import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js";
@@ -17,14 +23,30 @@ export const combatMethods = {
return !!(proto && proto.military);
},
// Base attack, lifted by the nation's infantry-weapons or artillery-shells
// research. Only the relevant stat for the unit's type applies.
_unitAttack(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.attack === "number" ? proto.attack : 0;
let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
if (proto && (proto.infantry || proto.artillery)) {
const modifiers = this.getCivModifiers(unit.civ);
const bonus = proto.infantry
? modifiers[EFFECT_INFANTRY_ATTACK]
: modifiers[EFFECT_ARTILLERY_ATTACK];
attack *= 1 + (bonus || 0);
}
return attack;
},
// Base defense, lifted by the nation's body-armour research for infantry.
_unitDefense(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.defense === "number" ? proto.defense : 0;
let defense = proto && typeof proto.defense === "number" ? proto.defense : 0;
if (proto && proto.infantry) {
const bonus = this.getCivModifiers(unit.civ)[EFFECT_INFANTRY_DEFENSE] || 0;
defense *= 1 + bonus;
}
return defense;
},
// Fraction of extra defense granted by the tile a unit occupies (terrain
@@ -34,12 +56,14 @@ export const combatMethods = {
return defenseMultiplier(tile, !!this.cityAt(coords));
},
// A unit's base defense, boosted by its tile, and doubled when it is the
// defending side of the exchange.
_effectiveDefense(unit, isDefender) {
// A unit's base defense, boosted by its tile and any fortifications or
// trenches there, and doubled when it is the defending side of the exchange.
// The attacker's type decides whether trench and fort bonuses use their melee
// or artillery figures.
_effectiveDefense(unit, isDefender, attackerProto = null) {
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
return defense;
return defense * this._tileFortificationMultiplier(unit.coords, attackerProto);
},
// Unique attacker/defender pairs. A pair is two hostile military units on the
@@ -71,7 +95,13 @@ export const combatMethods = {
!this._isAirUnit(other)
);
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
// Damage lands on infantry first, then the other fighting units, so a stack
// soaks blows with its footsoldiers before exposing support troops.
const rank = (unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry ? 0 : 1;
};
enemies.sort((a, b) => (rank(a) - rank(b)) || (a.hp - b.hp) || (a.id - b.id));
return enemies[0];
},
@@ -171,12 +201,14 @@ export const combatMethods = {
for (const { attacker, defender } of this._combatPairs()) {
const toDefender = Math.max(
1,
this._unitAttack(attacker) - this._effectiveDefense(defender, true)
this._unitAttack(attacker) -
this._effectiveDefense(defender, true, this.unitProto(attacker))
);
damage.set(defender, (damage.get(defender) || 0) + toDefender);
const toAttacker = Math.max(
1,
this._unitAttack(defender) - this._effectiveDefense(attacker, false)
this._unitAttack(defender) -
this._effectiveDefense(attacker, false, this.unitProto(defender))
);
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
}
@@ -206,6 +238,9 @@ export const combatMethods = {
for (const unit of this.units) {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
const coords = unit.coords;
// Support units and unaccompanied artillery are taken from underfoot.
this._captureSupportUnits(unit);
this._captureTileImprovement(coords, unit.civ);
if (this._hasHostileOn(coords, unit.civ)) continue;
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (!city || city.civ === unit.civ || claimed.has(city.id)) continue;
@@ -214,6 +249,56 @@ export const combatMethods = {
}
},
// An enemy mobile radar, launch or unaccompanied artillery on the captor's
// tile changes hands, or is scuttled when the captor is a warship. Artillery
// escapes capture while any of its own infantry stands beside it.
_captureSupportUnits(captor) {
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
const captorProto = this.unitProto(captor);
const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
if (enemies.length === 0) return false;
const enemyInfantry = enemies.some((unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry;
});
let changed = false;
for (const enemy of enemies) {
const proto = this.unitProto(enemy);
if (!proto) continue;
if (proto.capturable) {
// A warship over a launch stack simply sinks it, unprotected as it is.
if (proto.naval && captorProto && captorProto.naval) {
this._destroyUnit(enemy);
} else {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
}
changed = true;
} else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
enemy.civ = captor.civ;
enemy.strikeTarget = null;
changed = true;
}
}
if (changed) {
this._visibilityDirty = true;
this._emitChanged();
}
return changed;
},
// Enemy aircraft caught on a captured city's airfield change allegiance.
_captureCityAircraft(city, newCiv) {
for (const unit of this.unitsAt(city.coords)) {
const proto = this.unitProto(unit);
if (!proto || !proto.air || unit.civ === newCiv) continue;
unit.civ = newCiv;
unit.homeCityId = city.id;
unit.strikeTarget = null;
}
this._visibilityDirty = true;
},
_destroyUnit(unit) {
const index = this.units.indexOf(unit);
if (index >= 0) this.units.splice(index, 1);
@@ -223,6 +308,14 @@ export const combatMethods = {
// Called when a unit finishes stepping onto a tile. A military unit entering
// an enemy city captures it, but only once every defender has fallen.
_onUnitArrived(unit) {
// Walking onto a defenceless support unit or unaccompanied artillery takes
// it, even while other enemies hold the tile.
this._captureSupportUnits(unit);
// A military unit also captures a defenceless enemy improvement underfoot,
// such as a radar tower, whether or not a fight is still going on.
if (this._isMilitary(unit) && !this._isAirUnit(unit)) {
this._captureTileImprovement(unit.coords, unit.civ);
}
if (this._hasHostileOn(unit.coords, unit.civ)) return;
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
@@ -247,6 +340,10 @@ export const combatMethods = {
}
}
for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1);
// Losing a unit costs the owner standing at home; every enemy that was
// there to see it gains.
this._shiftOwnPopularity(unit.civ, -POPULARITY.unitLoss);
for (const enemy of enemies) this._shiftOwnPopularity(enemy, POPULARITY.unitKill);
}
},
@@ -289,6 +386,8 @@ export const combatMethods = {
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
);
}
// Fortifications holding a battlefield are battered by the fighting.
this._damageTileImprovement(battle.coords, BATTLE.structureDamagePerDay);
}
},
+14
View File
@@ -7,6 +7,20 @@ export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
// Hit points of the military tile improvements, so they can be worn down by
// ranged fire before being knocked out.
export const TILE_IMPROVEMENT_HP = {
fortifications: 2000,
radar_tower: 600,
coastal_cannon: 800,
};
// A coastal cannon's hourly volley against a hostile warship.
export const COASTAL_CANNON = { damage: 120 };
// How long infantry take to board a launch.
export const EMBARK_HOURS = 24;
// The calendar lives in one place: re-exported so model code can keep
// importing every simulation constant from this module.
export { HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR } from "../game_clock.js";
+158 -16
View File
@@ -7,8 +7,12 @@ import {
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
gdpPerCapitaFactors,
@@ -74,6 +78,7 @@ export const economyMethods = {
pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
// Fighting scars the tile too, but that loss recovers over time.
battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
trench: this.trenches.has(k),
regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
regionalLabel: region ? region.name : null,
nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
@@ -103,8 +108,10 @@ export const economyMethods = {
}
// Running propaganda campaigns are upkeep too, paid hour by hour.
total += this.getCampaignUpkeep(civ);
// The transport network costs an hourly maintenance per tile held.
// The transport network and military tile improvements cost an hourly
// maintenance per tile and structure held.
total += this.getTransportUpkeep(civ);
total += this.getTileImprovementUpkeep(civ);
return total;
},
@@ -251,13 +258,21 @@ export const economyMethods = {
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
const transportSources = this._transportUpkeepSources(civ);
const transportUpkeep = transportSources.reduce((sum, source) => sum + source.amount, 0);
const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
const tileImprovementUpkeep =
tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
return {
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
transportUpkeep: { total: transportUpkeep, sources: transportSources, groups: transportSources },
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep,
tileUpkeep: {
total: tileImprovementUpkeep,
sources: tileImprovementSources,
groups: tileImprovementSources,
},
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
@@ -468,19 +483,40 @@ export const economyMethods = {
return totals;
},
// Nation-wide modifier totals, memoised because pathfinding reads the rail
// speed for every tile it expands and rebuilding the totals there is costly.
getCivModifiers(civ) {
const cached = this._civModifierCache.get(civ);
if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = this.getCivBuildingModifiers(civ);
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
this._civModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
// ------------------------------------------- research and culture --
getResearch(civ) {
return this.research.get(civ) || 0;
// Invalidates the memoised modifier totals after anything that shapes them
// changes: research, buildings or government.
_touchModifiers() {
this._modifiersVersion += 1;
},
// Construction and training take this much longer than the base time; a
// construction technology level of 1% shortens it by 1%.
constructionSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_CONSTRUCTION_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// The time multiplier for travelling a railway: a trains technology level of
// 1% moves 1% faster along rail.
railSpeedMultiplier(civ) {
const bonus = this.getCivModifiers(civ)[EFFECT_RAIL_SPEED] || 0;
return 1 / (1 + Math.max(-0.99, bonus));
},
// ------------------------------------------- research and culture --
getCulture(civ) {
return this.culture.get(civ) || 0;
},
@@ -508,23 +544,128 @@ export const economyMethods = {
canResearchTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.technologies.length) return false;
if (this.isResearched(civ, index)) return false;
return prerequisitesMet(
this.technologies[index],
this.researched.get(civ) || new Set(),
this.technologies
);
const tech = this.technologies[index];
// A repeatable technology may be researched again; any other is done once.
if (!tech.repeatable && this.isResearched(civ, index)) return false;
return prerequisitesMet(tech, this.researched.get(civ) || new Set(), this.technologies);
},
// The technology a nation is currently focusing its research on, or -1.
getFocusedTechnology(civ) {
const index = this.focusTechnology.get(civ);
return index === undefined ? -1 : index;
},
// The research points already committed to a technology. With no index the
// current focus's progress is returned, which is what the panel usually wants.
getTechnologyProgress(civ, index = this.getFocusedTechnology(civ)) {
if (index < 0) return 0;
const map = this.techProgress.get(civ);
return map ? map.get(index) || 0 : 0;
},
// How many times a repeatable technology has been researched.
getTechnologyRepeatCount(civ, index) {
const tech = this.technologies[index];
if (!tech) return 0;
const counts = this.repeatCounts.get(civ);
return counts ? counts.get(tech.id) || 0 : 0;
},
// The cost of the next level of a technology, growing 2% each repeat.
getTechnologyCost(civ, index) {
return technologyCost(this.technologies[index], this.getTechnologyRepeatCount(civ, index));
},
getCivTechnologyModifiers(civ) {
const totals = {};
const set = this.researched.get(civ) || new Set();
for (const index of set) {
mergeModifiers(totals, technologyEffects(this.technologies[index]));
const civResearch = this.researched.get(civ) || new Set();
for (const index of civResearch) {
const tech = this.technologies[index];
if (!tech || tech.repeatable) continue;
mergeModifiers(totals, technologyEffects(tech));
}
// Repeatable technologies stack their effect once per researched level.
const counts = this.repeatCounts.get(civ);
if (counts) {
for (const [id, count] of counts) {
const tech = technologyById(id);
if (!tech || count <= 0) continue;
for (const e of tech.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, count);
}
}
}
return totals;
},
// -------------------------------------------------------- research --
// Points a nation creates flow straight into its focused technology; there is
// no bank, so time spent with nothing focused is simply time not advancing.
_addResearch(civ, amount) {
if (!(amount > 0)) return;
const index = this.getFocusedTechnology(civ);
if (index < 0) return;
this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
},
// Records the points committed to one technology.
_setTechnologyProgress(civ, index, progress) {
let map = this.techProgress.get(civ);
if (!map) {
map = new Map();
this.techProgress.set(civ, map);
}
if (progress > 0) map.set(index, progress);
else map.delete(index);
},
// Commits `progress` points to a technology. Any overflow past its cost is
// discarded: nothing is carried over to whatever is focused next.
_applyTechnologyProgress(civ, index, progress) {
const cost = this.getTechnologyCost(civ, index);
if (progress < cost) {
this._setTechnologyProgress(civ, index, progress);
return;
}
this._discoverTechnology(civ, index);
// The completed level spends its points; a repeatable starts the next level
// from zero. Other technologies keep whatever they had banked.
this._setTechnologyProgress(civ, index, 0);
this._clearFocus(civ);
this._gdpPerCapitaCache.clear();
this._emitChanged();
},
// Clears the focus only: progress already committed to any technology stays
// put, so switching focus and coming back resumes where the nation left off.
_clearFocus(civ) {
this.focusTechnology.set(civ, -1);
},
// Marks a technology known. A repeatable one instead counts one more level;
// its notice is flagged so the news list can stay quiet while the feed still
// tells the player.
_discoverTechnology(civ, index) {
const tech = this.technologies[index];
if (!tech) return;
if (tech.repeatable) {
const counts = this.repeatCounts.get(civ);
if (counts) counts.set(tech.id, (counts.get(tech.id) || 0) + 1);
} else {
this.researched.get(civ).add(index);
}
this._touchModifiers();
this._addNews({
type: NEWS_TECHNOLOGY,
civ,
technology: tech.id,
name: tech.name,
repeatable: !!tech.repeatable,
});
},
getBudget(civ) {
return this.budgets.has(civ) ? this.budgets.get(civ) : 0;
},
@@ -568,7 +709,7 @@ export const economyMethods = {
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
this.research.set(civ, this.getResearch(civ) + scienceRate);
this._addResearch(civ, scienceRate);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
@@ -628,6 +769,7 @@ export const economyMethods = {
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
},
// Units resting inside one of their own cities recover a little health each
+49
View File
@@ -0,0 +1,49 @@
// Month-over-month tracking for the nation summary bar. Once a calendar month
// rolls over, the nation figures captured at the start of the month that just
// ended become the baseline the current month is compared against, so every
// indicator can show whether it rose or fell since "the past month". January
// 2000 has no baseline, so it carries no change indicator.
import { unixTime } from "../game_clock.js";
export const monthlyMethods = {
// The absolute month number of an in-game hour, so a rollover is a simple
// comparison even across a year boundary.
_monthIndexOf(totalHours) {
const date = new Date(unixTime(totalHours) * 1000);
return date.getUTCFullYear() * 12 + date.getUTCMonth();
},
// Captures the starting month once the world exists.
_initMonthly() {
this._monthIndexNow = this._monthIndexOf(this.totalHours);
this._currentMonthStats = this._captureMonthlyStats();
this.monthlyStats = null;
},
// Called every hour; only a calendar-month change does any work.
_tickMonthly() {
const index = this._monthIndexOf(this.totalHours);
if (index === this._monthIndexNow) return;
// What was current at the start of the month that just ended is now the
// baseline for the new month.
this.monthlyStats = this._currentMonthStats;
this._currentMonthStats = this._captureMonthlyStats();
this._monthIndexNow = index;
},
// One record per civilisation of the indicators the summary bar compares.
_captureMonthlyStats() {
return this.civilisations.map((_, civ) => {
const headline = this._civHeadline(civ);
return {
population: headline.population,
gdp: headline.gdp,
gdpPerCapita: headline.gdpPerCapita,
popularity: this.getCivPopularity(civ),
approval: this.getCivApproval(civ),
budget: this.getBudget(civ),
};
});
},
};
+47 -11
View File
@@ -316,16 +316,20 @@ export const movementMethods = {
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
}
// A road or railway levels the terrain: every such tile costs the same to
// cross, a railway half again as cheap as a road.
return this._tileMovementMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
// cross, a railway half again as cheap as a road, and a nation's trains
// technology shortens its own railways further.
return this._tileMovementMultiplier(coords, unit.civ) / this._effectiveSpeedAt(unit, coords);
},
// The cheapest transport multiplier a tile offers: its railway, else its road,
// else the terrain's own. Flat across the whole tile, so a road levels hills
// and forests alike.
_tileMovementMultiplier(coords) {
// and forests alike. A nation's trains technology speeds up its railways only.
_tileMovementMultiplier(coords, civ = -1) {
const k = key(coords.x, coords.y);
if (this.railways.has(k)) return TRANSPORT_BY_ID.railway.movementCostMultiplier;
if (this.railways.has(k)) {
const base = TRANSPORT_BY_ID.railway.movementCostMultiplier;
return civ >= 0 ? base * this.railSpeedMultiplier(civ) : base;
}
if (this.roads.has(k)) return TRANSPORT_BY_ID.road.movementCostMultiplier;
return this.terrainStats.movementCostMultiplier(coords);
},
@@ -333,11 +337,14 @@ export const movementMethods = {
_fastestTileTravelHours(unit) {
// A railway (or, failing that, a road) is the cheapest possible tile, so the
// heuristic must use it when it beats the most generous terrain or A* could
// overestimate and miss the cheapest route.
// overestimate and miss the cheapest route. A railway can be cheaper still
// for a nation with trains technology.
const railway = TRANSPORT_BY_ID.railway.movementCostMultiplier *
this.railSpeedMultiplier(unit.civ);
const fastest = Math.min(
this._minMovementCost,
TRANSPORT_BY_ID.road.movementCostMultiplier,
TRANSPORT_BY_ID.railway.movementCostMultiplier
railway
);
return fastest / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
},
@@ -377,7 +384,7 @@ export const movementMethods = {
// Without it every candidate tile scans the whole army; the search therefore
// looks up occupancy in constant time, exactly as the browser's prediction
// does in map_view/motion.js.
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null) {
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null, zoc = null, from = null) {
const proto = this.unitProto(unit);
const air = this._isAirProto(proto);
if (air) {
@@ -388,6 +395,14 @@ export const movementMethods = {
} else if (!this.canEnter(unit.civ, proto, coords)) {
return false;
}
// Zone of control: a hostile unit denies its own tile and the six around it.
// Troops may enter or leave the ring, but never cross straight from one
// denied tile into another. A deliberate attack on the goal is exempt, so
// the ring never shields a garrison from a charge.
if (!air && !isGoal && zoc && from &&
zoc.has(key(from.x, from.y)) && zoc.has(key(coords.x, coords.y))) {
return false;
}
const owner = this.civAt(coords);
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
const occupants = occupancy
@@ -429,6 +444,22 @@ export const movementMethods = {
return index;
},
// The tiles denied to `civ` by hostile ground forces: every hostile military
// unit's tile and the six around it. Aircraft project no zone.
_zoneOfControl(civ) {
const denied = new Set();
for (const other of this.units) {
if (other.civ === civ) continue;
if (!this._isMilitary(other) || this._isAirUnit(other)) continue;
const k = key(other.coords.x, other.coords.y);
denied.add(k);
for (const neighbour of this._neighbours(other.coords)) {
denied.add(key(neighbour.x, neighbour.y));
}
}
return denied;
},
// `pathCache` (optional) shares routes between units whose civ, type, start
// and goal are identical: the route shape depends only on the unit type's
// traversable terrain and the owning civ's territory/fog, while movement
@@ -441,6 +472,7 @@ export const movementMethods = {
if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
const occupancy = this._buildOccupancy(unit);
const targetCiv = this._targetCivFor(unit, goal, occupancy);
const zoc = this._zoneOfControl(unit.civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
@@ -448,10 +480,14 @@ export const movementMethods = {
start,
goal,
(coords) => this._neighbours(coords),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
(coords, isGoal, from) => {
const fromKey = from ? key(from.x, from.y) : "";
const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy));
enterCache.set(
k,
this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy, zoc, from)
);
}
return enterCache.get(k);
},
+38 -8
View File
@@ -45,7 +45,9 @@ export const orderMethods = {
protoIndex,
capacity,
elapsedHours: 0,
totalHours: (proto.cost / capacity) * this.constructionApprovalMultiplier(city),
totalHours: (proto.cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -84,6 +86,7 @@ export const orderMethods = {
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
@@ -97,6 +100,7 @@ export const orderMethods = {
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
@@ -109,7 +113,9 @@ export const orderMethods = {
level,
capacity,
elapsedHours: 0,
totalHours: (cost / capacity) * this.constructionApprovalMultiplier(city),
totalHours: (cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -143,6 +149,26 @@ export const orderMethods = {
return true;
},
// Removes whatever transport improvement stands on a land tile the
// civilisation controls. Removal is immediate and free, so a mis-built road
// can be cleared without a new one replacing it.
requestRemoveImprovement(civ, coords) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (!coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
const present = this.railways.has(k) || this.roads.has(k);
if (!present) return false;
this.roads.delete(k);
this.railways.delete(k);
this._improvementVersion += 1;
this._emitChanged();
return true;
},
requestDemolish(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
@@ -155,6 +181,7 @@ export const orderMethods = {
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
@@ -169,17 +196,20 @@ export const orderMethods = {
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
requestResearchTechnology(civ, index) {
// Focuses a nation's research on one technology. Every point it creates from
// now on flows straight into it; nothing is charged up front and nothing is
// banked while no technology is focused.
requestFocusTechnology(civ, index) {
if (!this.canResearchTechnology(civ, index)) return false;
const technology = this.technologies[index];
if (this.getResearch(civ) < technology.cost) return false;
this.research.set(civ, this.getResearch(civ) - technology.cost);
this.researched.get(civ).add(index);
this._gdpPerCapitaCache.clear();
if (this.getFocusedTechnology(civ) === index) return true;
// Progress is kept per technology, so switching focus never discards work
// already done on either the old focus or the new one.
this.focusTechnology.set(civ, index);
this._emitChanged();
return true;
},
+108 -20
View File
@@ -27,9 +27,11 @@ import {
REBELLION,
WAR_DECLARATION,
CULTURE_IMPACT,
POPULARITY,
} from "../data/politics.js";
import { NEWS_POLICY } from "../data/relations.js";
import { EFFECT_CULTURE } from "../data/effects.js";
import { popularityDisplay, popularityPoints } from "../rules.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
// How far from one of its cities an ethnicity can meaningfully spread. Beyond
@@ -57,7 +59,6 @@ export const politicsMethods = {
_tickPolitics() {
this._tickCampaigns();
this._tickOpinionDrift();
this._tickMigration();
this._tickUprisings();
this._tickCultureImpact();
@@ -204,6 +205,19 @@ export const politicsMethods = {
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
},
// One ethnicity's share of a civilisation's whole population, 0 when it has
// no presence there. Used to bound expulsions.
getCivEthnicShare(civ, ethnicity) {
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
let total = 0;
let target = 0;
for (const [eth, count] of counts) {
total += count;
if (eth === ethnicity) target += count;
}
return total > 0 ? target / total : 0;
},
// The ethnic makeup of one city's region.
getCityEthnicMakeup(city) {
const scale = this._populationBalance.get(city.civ) || 1;
@@ -295,8 +309,10 @@ export const politicsMethods = {
}
},
// Opinions slide one point an hour back toward neutral, so the rate is a
// constant 1 point/h and the time to neutrality is simply the points left.
_tickOpinionDrift() {
const step = OPINION.driftPerDay;
const step = POPULARITY.driftPerHour * POPULARITY.opinionPerPoint;
this._driftStore(this.ethnicOpinions, step);
this._driftStore(this.govOpinions, step);
},
@@ -316,42 +332,74 @@ export const politicsMethods = {
// ------------------------------------------------------ approval --
// A city region's approval of its government: the population-weighted mean of
// every resident population's opinion of it.
getCityApproval(city) {
// A city region's opinion of its government, in one pass: the linear
// population-weighted approval (which drives tax and construction) and the
// same figure in integer popularity points (what the player is shown).
getCityOpinions(city) {
const version = this._politicsVersion;
const cached = this._approvalCache.get(city.id);
if (cached && cached.version === version) return cached.value;
const value = this._weightedApproval(this.regionTiles(city), city.civ);
this._approvalCache.set(city.id, { version, value });
return value;
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this.regionTiles(city), city.civ);
const record = { version, ...value };
this._approvalCache.set(city.id, record);
return record;
},
// A nation's government approval among its own people. Memoised against the
// politics version, like getCityApproval, since the snapshot reads it for
// A nation's opinion figures among its own people. Memoised against the
// politics version, like getCityOpinions, since the snapshot reads it for
// every nation on every broadcast.
getCivApproval(civ) {
getCivOpinions(civ) {
const version = this._politicsVersion;
const cached = this._civApprovalCache.get(civ);
if (cached && cached.version === version) return cached.value;
const value = this._weightedApproval(this._territoryByCiv.get(civ) || [], civ);
this._civApprovalCache.set(civ, { version, value });
return value;
if (cached && cached.version === version) return cached;
const value = this._weightedOpinions(this._territoryByCiv.get(civ) || [], civ);
const record = { version, ...value };
this._civApprovalCache.set(civ, record);
return record;
},
_weightedApproval(coordsList, government) {
getCityApproval(city) {
return this.getCityOpinions(city).approval;
},
getCivApproval(civ) {
return this.getCivOpinions(civ).approval;
},
getCityPopularity(city) {
return this.getCityOpinions(city).popularity;
},
getCivPopularity(civ) {
return this.getCivOpinions(civ).popularity;
},
// What the player sees, in 0..1: the popularity points through the logistic.
getCityPopularityDisplay(city) {
return popularityDisplay(this.getCityPopularity(city));
},
getCivPopularityDisplay(civ) {
return popularityDisplay(this.getCivPopularity(civ));
},
_weightedOpinions(coordsList, government) {
let total = 0;
let points = 0;
let weight = 0;
for (const coords of coordsList) {
const k = key(coords.x, coords.y);
const population = this.tilePopulation.get(k) || 0;
for (const [eth, share] of this.getTileEthnicFractions(coords)) {
const w = population * share;
total += w * this.getGovOpinion(eth, government);
const opinion = this.getGovOpinion(eth, government);
total += w * opinion;
points += w * popularityPoints(opinion);
weight += w;
}
}
return weight > 0 ? total / weight : OPINION.baseline;
if (weight <= 0) return { approval: OPINION.baseline, popularity: 0 };
return { approval: total / weight, popularity: Math.round(points / weight) };
},
// The multiplier on a city's construction time. Above the approval floor it
@@ -533,6 +581,11 @@ export const politicsMethods = {
if (!record) return false;
if (!isKnownPolicy(policyId)) return false;
if (this.hasPolicy(civ, policyId, ethnicity)) return false;
// A minority that makes up more than a tenth of the country cannot simply
// be expelled: its people are too many to drive out.
if (policyId === POLICY_EXPEL && this.getCivEthnicShare(civ, ethnicity) > 0.1) {
return false;
}
record.push({ id: this._nextPolicyId++, type: policyId, ethnicity });
this._applyPolicyOpinion(civ, policyId, ethnicity);
this._addNews({ type: NEWS_POLICY, civ, policy: policyId, ethnicity });
@@ -771,19 +824,54 @@ export const politicsMethods = {
this._addGrudge(observer, aggressor, -WAR_CRIME.tileOpinion * factor, until);
this._shiftEthnicOpinion(observer, aggressor, -WAR_CRIME.tileEthnicOpinion * factor);
}
// The world's governments also simply lose standing over the atrocity.
this._applyPillagePopularity(aggressor, coords);
this._touchPolitics();
},
// ------------------------------------------------ war and uprising --
// The world reacts to a declaration of war: onlookers who liked the victim
// turn on the aggressor, while those who loathed it quietly approve.
// turn on the aggressor, while those who loathed it quietly approve. On top
// of that the aggressor's own people resent the war, and a people rallied by
// an unprovoked attack on a government it already loathes take heart.
_applyWarPopularity(actor, target) {
for (let observer = 0; observer < this.civilisations.length; observer++) {
const stance = this.getGovOpinion(observer, target);
const delta = -WAR_DECLARATION.penalty * (stance - OPINION.baseline) * 2;
if (delta !== 0) this._shiftGovOpinion(observer, actor, delta);
}
this._shiftOwnPopularity(actor, -POPULARITY.declareWar);
// A people already hostile to the aggressor rallies behind its own
// government when that aggressor attacks.
const aggressorStanding =
this._weightedOpinions(this._territoryByCiv.get(target) || [], actor).approval;
if (aggressorStanding < POPULARITY.declaredOnThreshold) {
this._shiftOwnPopularity(target, POPULARITY.declaredOnBoost);
}
this._touchPolitics();
},
// Move a government's standing with its own people by `points`.
_shiftOwnPopularity(civ, points) {
if (!this._validCiv(civ) || points === 0) return;
const delta = points * POPULARITY.opinionPerPoint;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
for (const eth of counts.keys()) this._shiftGovOpinion(eth, civ, delta);
},
// The world turns against an aggressor that pillages a tile, with the peoples
// directly affected feeling it twice as sharply. It is a grudge, so it fades
// on the same clock as the rest of the war-crime memory.
_applyPillagePopularity(aggressor, coords) {
if (!this._validCiv(aggressor)) return;
const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
for (let observer = 0; observer < this.civilisations.length; observer++) {
this._addGrudge(observer, aggressor, amount, until);
}
// The tile's dominant people carry the double debuff.
this._addGrudge(this._dominantEthnicity(coords), aggressor, amount, until);
this._touchPolitics();
},
+113 -5
View File
@@ -3,6 +3,7 @@
import { parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
@@ -34,6 +35,10 @@ export const serializationMethods = {
// improvement version.
roads: this._serializeTransport(this.roads, "_serializedRoadsCache"),
railways: this._serializeTransport(this.railways, "_serializedRailwaysCache"),
// War-time tile works: trenches as [x, y], military improvements as
// [x, y, id, owner, hp].
trenches: this._serializeTrenches(),
tileImprovements: this._serializeTileImprovements(),
// Each land tile's region: [x, y, cityId], so the browser attributes a
// tile's GDP modifiers to the city that develops it.
regions: this._serializeRegions(),
@@ -45,7 +50,6 @@ export const serializationMethods = {
civStats: this._serializeCivStats(),
cityStats: this._serializeCityStats(),
budgets: this._serializeCivValues(this.budgets),
research: this._serializeCivValues(this.research),
culture: this._serializeCivValues(this.culture),
// Each nation's share of the world's cultural output (zero-sum) and the
// people's approval of every government.
@@ -66,16 +70,28 @@ export const serializationMethods = {
},
government: this._serializeCivValues(this.government),
researched: this._serializeResearched(),
// Focused research: the technology each nation is working on, the points
// committed to every technology it has started, and how many levels of
// each repeatable it has.
focusTechnology: this._serializeCivValues(this.focusTechnology),
techProgress: this._serializeTechProgress(),
repeatCounts: this._serializeRepeatCounts(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
news: this._serializeNews(),
battles: this._serializeBattles(),
// Each nation's indicators a month ago, for the summary change arrows;
// empty during January 2000, which has no past month.
monthly: this.monthlyStats || [],
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, queue]) => [
cityId,
queue.map((entry) => ({ ...entry })),
])
),
// The military improvements each nation has under construction, so the
// tile panel can show a progress bar like a city's production.
tileWorks: this._serializeTileWorks(),
// Which of the large per-tile collections changed since the last
// broadcast, so a viewer can rebuild only those and the server can omit
// the rest from the wire (see GameServer._broadcastState).
@@ -85,6 +101,7 @@ export const serializationMethods = {
population: this._populationVersion,
ethnicity: this._ethnicityVersion,
improvements: this._improvementVersion,
warfare: this._tileImprovementVersion,
},
};
},
@@ -102,20 +119,28 @@ export const serializationMethods = {
if (viewerCiv < 0) {
return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
upkeep: 0, research: 0, culture: 0, government: 0,
approval: 0.5, cultureImpact: 0, ethnicMakeup: [],
upkeep: 0, researchRate: 0, culture: 0, government: 0,
approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
constructionSpeed: 0, railSpeed: 0,
breakdown: null,
};
}
const headline = this._civHeadline(viewerCiv);
const modifiers = this.getCivModifiers(viewerCiv);
return {
...headline,
// Repeatable research the browser mirrors: construction and rail speed.
constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv),
research: this.getResearch(viewerCiv),
// The research this nation creates each hour, all of which flows into the
// focused technology. There is no stock of points.
researchRate: modifiers[EFFECT_RESEARCH] || 0,
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
approval: this.getCivApproval(viewerCiv),
popularity: this.getCivPopularity(viewerCiv),
cultureImpact: this.getCulturalImpact(viewerCiv),
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
@@ -177,6 +202,11 @@ export const serializationMethods = {
stepHours,
segmentHours,
waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]),
// A transport's cargo and any boarding still under way.
cargo: (unit.cargo || []).map((passenger) => ({ ...passenger })),
embarking: unit.embarking
? { hoursLeft: unit.embarking.hoursLeft, count: unit.embarking.ids.length }
: null,
};
});
},
@@ -197,6 +227,30 @@ export const serializationMethods = {
return result;
},
_serializeTrenches() {
const result = [];
for (const k of this.trenches) {
const coords = parseKey(k);
result.push([coords.x, coords.y]);
}
return result;
},
_serializeTileImprovements() {
const result = [];
for (const [k, id] of this.tileImprovements) {
const coords = parseKey(k);
result.push([
coords.x,
coords.y,
id,
this.tileImprovementOwner.get(k) ?? -1,
this.tileImprovementHp.get(k) || 0,
]);
}
return result;
},
// A sparse [x, y, cityId] list of every tile's region, memoised against the
// region version so an unchanged world is not re-walked on every broadcast.
_serializeRegions() {
@@ -243,6 +297,7 @@ export const serializationMethods = {
return this.civilisations.map((_, index) => ({
...this._civHeadline(index),
approval: this.getCivApproval(index),
popularity: this.getCivPopularity(index),
cultureImpact: this.getCulturalImpact(index),
}));
},
@@ -288,6 +343,42 @@ export const serializationMethods = {
return result;
},
// The points each nation has committed to every technology it has started,
// as { technologyIndex: points } objects. Work is never lost when the focus
// changes, so every started technology travels in the snapshot.
_serializeTechProgress() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const map = this.techProgress.get(i);
const record = {};
if (map) {
for (const [index, points] of map) {
if (points > 0) record[index] = points;
}
}
result.push(record);
}
return result;
},
// The tile improvements under construction, one array per nation, each entry
// { coords: [x, y], id, elapsedHours, totalHours }.
_serializeTileWorks() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const queue = this.tileWorks.get(i) || [];
result.push(
queue.map((work) => ({
coords: [work.coords.x, work.coords.y],
id: work.id,
elapsedHours: work.elapsedHours,
totalHours: work.totalHours,
}))
);
}
return result;
},
_serializeResearched() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
@@ -298,6 +389,23 @@ export const serializationMethods = {
return result;
},
// How many times each nation has researched every repeatable technology, as
// { technologyId: count } objects, skipping the unused ones.
_serializeRepeatCounts() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const counts = this.repeatCounts.get(i);
const record = {};
if (counts) {
for (const [id, count] of counts) {
if (count > 0) record[id] = count;
}
}
result.push(record);
}
return result;
},
// Every unordered pair with its current status. With a handful of
// civilisations the whole matrix is tiny, and it lets the client show the
// status of every nation without a second lookup.
@@ -392,7 +500,7 @@ export const serializationMethods = {
for (let a = 0; a < this.civilisations.length; a++) {
const row = [];
for (let b = 0; b < this.civilisations.length; b++) {
row.push(roundTo(this._weightedApproval(this._territoryByCiv.get(a) || [], b), 3));
row.push(roundTo(this._weightedOpinions(this._territoryByCiv.get(a) || [], b).approval, 3));
}
result.push(row);
}
+30
View File
@@ -3,6 +3,8 @@
import { key, parseKey } from "../hex.js";
import { NEWS_CAPTURE } from "../data/relations.js";
import { POPULARITY } from "../data/politics.js";
import { tileImprovementById } from "../data/improvements.js";
import { serializeCells } from "./helpers.js";
export const territoryMethods = {
@@ -60,6 +62,22 @@ export const territoryMethods = {
this._indexTerritory();
},
// The tiles a nation's fortifications anchor: a fort's own tile and its
// neighbours. Even if an enemy takes the nearby city, these tiles stay.
_fortifiedControl(civ) {
const anchored = new Set();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.permanentControl) continue;
if (this.tileImprovementOwner.get(k) !== civ) continue;
anchored.add(k);
for (const neighbour of this._neighbours(parseKey(k))) {
anchored.add(key(neighbour.x, neighbour.y));
}
}
return anchored;
},
// Subdivides each nation's territory into the regions of its own cities: a
// multi-source BFS grown from every city, but never crossing into another
// nation's land. Ties between equally close cities are settled by city order,
@@ -140,9 +158,13 @@ export const territoryMethods = {
if (oldCiv === newCiv) return false;
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
// Fortifications hold a permanent area of control: their tile and the ring
// around it stay with the builder even when the city nearby changes hands.
const anchored = this._fortifiedControl(oldCiv);
city.civ = newCiv;
for (const coords of region) {
const k = key(coords.x, coords.y);
if (anchored.has(k)) continue;
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
}
// The captured city's own tile always changes hands, ties or not.
@@ -151,6 +173,14 @@ export const territoryMethods = {
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
// Aircraft caught on the captured airfield change allegiance with it.
this._captureCityAircraft(city, newCiv);
// The city's buildings now serve a different nation, so its modifier
// totals changed hands.
this._touchModifiers();
// Taking a city heartens the conqueror's people and demoralises the losers.
this._shiftOwnPopularity(newCiv, POPULARITY.cityCapture);
this._shiftOwnPopularity(oldCiv, -POPULARITY.cityLoss);
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
+12 -2
View File
@@ -1,8 +1,9 @@
// Fog of war and the grid walkers. Explored cells stay known; visible cells
// are the ones currently watched by territory and units.
import { key } from "../hex.js";
import { key, parseKey } from "../hex.js";
import { UNIT_VISION, TERRITORY_VISION } from "./constants.js";
import { tileImprovementById } from "../data/improvements.js";
import { serializeSet } from "./helpers.js";
export const visibilityMethods = {
@@ -30,7 +31,16 @@ export const visibilityMethods = {
for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION);
}
for (const unit of this.units) {
this._reveal(unit.civ, unit.coords, UNIT_VISION);
const proto = this.unitProto(unit);
this._reveal(unit.civ, unit.coords, (proto && proto.vision) || UNIT_VISION);
}
// Fortifications and radar towers watch their own neighbourhood too.
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.vision) continue;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) continue;
this._reveal(owner, parseKey(k), Math.max(UNIT_VISION, proto.vision));
}
},
+362
View File
@@ -0,0 +1,362 @@
// War-time tile works: trenches dug by infantry, the military improvements a
// nation raises on its land (fortifications, radar towers, coastal cannons),
// and the damage, capture and upkeep that come with them.
import { key, parseKey } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
import { TRENCH } from "../data/combat.js";
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
export const warfareMethods = {
// ----------------------------------------------------------- trenches --
// Infantry dig in on a land tile their nation controls. A trench shelters
// the garrison from melee and artillery at the cost of local GDP.
requestDigTrench(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
const civ = units[0].civ;
if (!units.every((unit) => unit.civ === civ)) return false;
// Only infantry can dig, and only into their own land.
if (!units.every((unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry;
})) return false;
if (this.civAt(coords) !== civ) return false;
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile || tile.terrainClass !== "Land") return false;
const k = key(coords.x, coords.y);
if (this.trenches.has(k)) return false;
this.trenches.add(k);
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// ------------------------------------------------- tile improvements --
// Queues a military tile improvement on a land tile the nation controls. The
// work takes the same GDP-scaled time as any other construction and completes
// on the hourly tile-works tick.
requestBuildTileImprovement(civ, coords, id) {
if (!this._validCiv(civ) || !coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const proto = tileImprovementById(id);
if (!proto) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k)) return false;
if (proto.coastal && !this._isCoastalTile(coords)) return false;
const gdp = this.getPlayerGdp(civ);
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
if (!this._spendBudget(civ, proto.buildCost)) return false;
const work = {
coords: { x: coords.x, y: coords.y },
id,
elapsedHours: 0,
totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
};
const queue = this.tileWorks.get(civ);
if (queue) queue.push(work);
else this.tileWorks.set(civ, [work]);
this._emitChanged();
return true;
},
// Tears down a military improvement (or a trench) on a controlled tile.
requestRemoveTileImprovement(civ, coords) {
if (!this._validCiv(civ) || !coords) return false;
const k = key(coords.x, coords.y);
if (this.civAt(coords) !== civ) return false;
let removed = false;
if (this.trenches.delete(k)) removed = true;
if (this.tileImprovements.delete(k)) {
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
removed = true;
}
if (!removed) return false;
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// Advances every nation's queued tile works by an hour, completing the head
// of each queue and carrying the overflow into the next.
_tickTileWorks() {
for (const [civ, queue] of this.tileWorks) {
let hoursLeft = 1;
while (hoursLeft > 0 && queue.length > 0) {
const work = queue[0];
const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
if (hoursLeft >= remaining) {
hoursLeft -= remaining;
this._completeTileWork(civ, work);
queue.shift();
} else {
work.elapsedHours += hoursLeft;
hoursLeft = 0;
}
}
if (queue.length === 0) this.tileWorks.delete(civ);
}
},
_completeTileWork(civ, work) {
const k = key(work.coords.x, work.coords.y);
// The tile may have been taken or already improved while the work ran.
if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
const proto = tileImprovementById(work.id);
if (!proto) return;
this.tileImprovements.set(k, work.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._visibilityDirty = true;
this._emitChanged();
},
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_isCoastalTile(coords) {
for (const neighbour of this._neighbours(coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
_tileImprovementAt(coords) {
const id = this.tileImprovements.get(key(coords.x, coords.y));
return id ? tileImprovementById(id) : null;
},
// The extra defense a tile's fortifications and trenches give a defender,
// against a melee or artillery attacker.
_tileFortificationMultiplier(coords, attackerProto) {
const k = key(coords.x, coords.y);
const artillery = this._isArtilleryAttack(attackerProto);
let multiplier = 1;
const id = this.tileImprovements.get(k);
if (id) {
const proto = tileImprovementById(id);
if (proto && proto.defense) {
multiplier *= 1 + (artillery ? proto.defense.artillery : proto.defense.melee);
}
}
if (this.trenches.has(k)) {
multiplier *= artillery
? TRENCH.artilleryDefenseMultiplier
: TRENCH.meleeDefenseMultiplier;
}
return multiplier;
},
// Whether an attacker's blow is artillery (or aircraft) fire rather than a
// melee charge, so trench and fortification bonuses can tell them apart.
_isArtilleryAttack(proto) {
if (!proto) return false;
if (proto.artillery || proto.air) return true;
return (proto.range || 1) > 1;
},
// Damage a tile improvement from ranged fire. Fortifications and coastal
// cannons are knocked down when their hit points run out; a radar tower is
// captured by infantry rather than destroyed.
_damageTileImprovement(coords, amount) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !(proto.takesRangedDamage || proto.takesDamage || proto.takesCombatDamage)) {
return false;
}
const hp = (this.tileImprovementHp.get(k) || 0) - amount;
if (hp <= 0) {
this.tileImprovements.delete(k);
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
}
this.tileImprovementHp.set(k, hp);
return true;
},
// An enemy walking onto a capturable improvement takes it for their nation.
_captureTileImprovement(coords, civ) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !proto.capturable) return false;
const owner = this.tileImprovementOwner.get(k);
if (owner === civ) return false;
this.tileImprovementOwner.set(k, civ);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
// The hourly maintenance of every military improvement a nation holds.
getTileImprovementUpkeep(civ) {
let total = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (proto) total += proto.maintenance;
}
return total;
},
_tileImprovementUpkeepSources(civ) {
const byType = new Map();
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
entry.amount += proto.maintenance;
entry.count += 1;
byType.set(id, entry);
}
return Array.from(byType.values()).filter((entry) => entry.amount > 0);
},
// ------------------------------------------------------ transports --
// Infantry board a friendly launch. They must stand on the shore beside it;
// the boarding takes EMBARK_HOURS in-game hours to complete.
requestEmbark(infantryIds, launchId) {
const launch = this.findUnit(Number(launchId));
if (!launch) return false;
const proto = this.unitProto(launch);
if (!proto || !(proto.transportCapacity > 0)) return false;
if (launch.embarking) return false;
const cargo = launch.cargo || [];
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
const unitProto = this.unitProto(unit);
return unitProto && unitProto.infantry && unit.civ === launch.civ;
});
if (infantry.length === 0) return false;
if (cargo.length + infantry.length > proto.transportCapacity) return false;
// The troops must be on the shore next to the launch.
if (!infantry.every((unit) =>
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
return false;
}
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
this._emitChanged();
return true;
},
// Finishes any boarding: at zero hours the infantry leave the map and become
// the launch's cargo, and are carried until put ashore.
_tickEmbark() {
for (const unit of this.units) {
if (!unit.embarking) continue;
unit.embarking.hoursLeft -= 1;
if (unit.embarking.hoursLeft > 0) continue;
const proto = this.unitProto(unit);
const capacity = proto ? proto.transportCapacity || 0 : 0;
if (!unit.cargo) unit.cargo = [];
for (const id of unit.embarking.ids) {
if (unit.cargo.length >= capacity) break;
const infantry = this.findUnit(id);
if (!infantry || infantry.civ !== unit.civ) continue;
unit.cargo.push({ proto: infantry.proto, hp: infantry.hp });
this._destroyUnit(infantry);
this._visibilityDirty = true;
}
unit.embarking = null;
this._emitChanged();
}
},
// Puts a whole stack of launches' cargo ashore on an adjacent coastal tile.
requestDisembark(launchIds, coords) {
if (!coords) return false;
const launches = this._unitsForOrder(launchIds).filter((unit) => {
const proto = this.unitProto(unit);
return proto && proto.transportCapacity > 0;
});
if (launches.length === 0) return false;
const civ = launches[0].civ;
if (!launches.every((unit) => unit.civ === civ)) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (!launches.every((unit) => this.topology.tileDistance(unit.coords, coords) === 1)) {
return false;
}
let landed = false;
for (const launch of launches) {
const cargo = launch.cargo || [];
for (const passenger of cargo) {
const proto = this.protoUnits[passenger.proto];
if (!proto) continue;
const unit = this._spawnUnit(coords, civ, proto);
if (passenger.hp !== undefined) unit.hp = Math.min(proto.maxHp, passenger.hp);
landed = true;
}
launch.cargo = [];
}
if (landed) {
this._visibilityDirty = true;
this._emitChanged();
}
return landed;
},
// -------------------------------------------------- coastal cannons --
// Coastal cannons fire at hostile warships within range every hour.
_tickCoastalCannons() {
const damaged = new Map();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.automatic) continue;
const owner = this.tileImprovementOwner.get(k);
const coords = parseKey(k);
for (const target of this._unitsWithin(coords, proto.range || 1)) {
const targetProto = this.unitProto(target);
if (!targetProto || !targetProto.naval) continue;
if (target.civ === owner) continue;
const amount = COASTAL_CANNON.damage;
damaged.set(target, (damaged.get(target) || 0) + amount);
}
}
if (damaged.size === 0) return;
const destroyed = [];
for (const [unit, amount] of damaged) {
unit.hp -= amount;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// Every unit of another nation within `radius` tiles of `coords`.
_unitsWithin(coords, radius) {
const result = [];
const origin = { x: coords.x, y: coords.y };
for (const unit of this.units) {
if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit);
}
return result;
},
};
+15 -8
View File
@@ -4,6 +4,7 @@
import { ECONOMY } from "../data/economy.js";
import { ROADS } from "../data/roads.js";
import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js";
import { MapGenerator } from "../map_generator.js";
@@ -173,20 +174,23 @@ export const worldMethods = {
return candidate;
},
// Lays the pre-generated road network. Cities are founded with a road, so
// they are free nodes while the network is drawn; the chosen links are the
// greedy spanner over every possible city-to-city route.
// Lays the pre-generated road network. Cities are founded on a railway (they
// improve themselves), so they are free nodes while the network is drawn; the
// chosen links are the greedy spanner over every possible city-to-city route.
_generateRoads(roadKeys = null) {
for (const city of this.cities) {
if (!city.improvements.includes(ROADS.improvementName)) {
city.improvements.push(ROADS.improvementName);
}
}
const railwayName = transportImprovement("railway").name;
// A cached world hands back the (read-only) road tiles; copy them so no
// state can alter the cache.
this.roads = roadKeys
? new Set(roadKeys)
: buildRoadNetwork(this.cities, this.topology, this.tiles);
// A city always stands on a railway, so no player has to improve it.
for (const city of this.cities) {
if (!city.improvements.includes(railwayName)) city.improvements.push(railwayName);
const k = key(city.coords.x, city.coords.y);
this.roads.delete(k);
this.railways.add(k);
}
},
_spawnUnit(center, civIndex, proto, homeCityId = null) {
@@ -214,6 +218,9 @@ export const worldMethods = {
strikeTarget: null,
strikeReadyHour: 0,
missionFlown: false,
// A transport's cargo and any boarding under way.
cargo: [],
embarking: null,
};
if (proto.air) {
unit.homeCityId = homeCityId;
+6 -4
View File
@@ -2,14 +2,16 @@
// the graph with callbacks so the algorithm stays pure and testable:
//
// neighbours(coords) -> [{x,y}, ...]
// canEnter(coords, isGoal) -> bool
// canEnter(coords, isGoal, from) -> bool
// stepCost(from, to) -> number
// heuristic(from, to) -> number
//
// canEnter is told whether the tile is the destination, so a caller can allow
// entering a tile it would otherwise route around (a hostile unit or city) only
// as the very goal. findPath returns the cells from start to goal inclusive, or
// [] when the goal cannot be entered or is unreachable.
// as the very goal, and which tile the step came from, so a caller can forbid
// crossing from one denied tile straight into another. findPath returns the
// cells from start to goal inclusive, or [] when the goal cannot be entered or
// is unreachable.
import { key } from "./hex.js";
@@ -97,7 +99,7 @@ export class HexPathfinder {
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
if (!canEnter(neighbour, neighbourKey === goalKey, current)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
+23 -1
View File
@@ -1,7 +1,25 @@
// Catalogue maths shared by the server (which charges costs) and the browser
// (which displays and disables buttons) so the numbers always agree.
import { effectValue, CITY_DEFENSE_BONUS, ECONOMY } from "./data.js";
import { effectValue, CITY_DEFENSE_BONUS, ECONOMY, POPULARITY, TRENCH } from "./data.js";
// The popularity scale shared by the server (which simulates it) and the
// browser (which shows it). Opinions live on the 0..1 scale; popularity is the
// same figure in integer points centred on neutral 0, and what the player sees
// is a logistic of those points.
export function popularityPoints(opinion) {
return Math.round((opinion - 0.5) / POPULARITY.opinionPerPoint);
}
export function popularityDisplay(points) {
return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points));
}
// The hourly pace at which a population at `points` returns to neutral: one
// point an hour, so the estimate is simply the distance left.
export function hoursToNeutral(points) {
return Math.abs(points);
}
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP.
@@ -100,6 +118,7 @@ export function gdpPerCapitaFactors(tile, {
nextToCity = false,
pillagePenalty = 1,
battleDeficit = 0,
trench = false,
regionalMultiplier = 1,
regionalLabel = null,
nationalMultiplier = 1,
@@ -117,6 +136,9 @@ export function gdpPerCapitaFactors(tile, {
if (pillagePenalty !== 1) {
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
}
if (trench) {
factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
}
// City improvements only shape the region their city controls, so a tile's
// GDP per capita carries its region's multiplier plus the nation-wide one
// from government and research. Both are factors on the per-capita figure,