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:
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+7
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user