Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
* In some circumstances, it seems likes aircraft cannot bomb within their own range, as per an error message reported by a user.
|
||||
* War crime (pillaging) seems to have a permanent effect on popularity that I just cannot get rid off. War crime popularity debuff should be instant and only once per occurence.
|
||||
* Double airplanes range
|
||||
@@ -2,8 +2,6 @@
|
||||
// every money string reads in the right unit. The symbol is set from the
|
||||
// snapshot; until then a neutral mark stands in.
|
||||
|
||||
import { groupDigits } from "../../shared/text_format.js";
|
||||
|
||||
let symbol = "¤";
|
||||
|
||||
export function setCurrency(currency) {
|
||||
@@ -13,10 +11,3 @@ export function setCurrency(currency) {
|
||||
export function currencySymbol() {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
// A signed money amount, with an optional "/h" for hourly figures.
|
||||
export function moneyText(value, { signed = true, perHour = false } = {}) {
|
||||
const amount = `${symbol}${groupDigits(Math.round(Math.abs(value)))}`;
|
||||
const sign = signed ? (value < 0 ? "−" : "+") : "";
|
||||
return `${sign}${amount}${perHour ? "/h" : ""}`;
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ export class GameFeed {
|
||||
return $line;
|
||||
}
|
||||
|
||||
lineCount() {
|
||||
return this.$container ? this.$container.children(".feed-line").length : 0;
|
||||
}
|
||||
|
||||
_retire($line) {
|
||||
$line.addClass("leaving");
|
||||
if (this.fade <= 0) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// on the GameScreen instance (composed in ../game_screen.js) and only read the
|
||||
// snapshot and forward orders.
|
||||
|
||||
import { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js";
|
||||
import { describeNews, marketPricesFrom, quotedBuildCost } from "../modals.js";
|
||||
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
|
||||
import {
|
||||
CIVILISATIONS,
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
resourceById,
|
||||
RESOURCES,
|
||||
RESOURCE_RULES,
|
||||
resourceBuildingOutputAt,
|
||||
tileImprovementById,
|
||||
AIR_STRIKE,
|
||||
} from "../../../shared/data.js";
|
||||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||||
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||||
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||||
import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { isEconomicMode } from "../map_view/economic.js";
|
||||
@@ -386,12 +386,11 @@ export const panelMethods = {
|
||||
},
|
||||
|
||||
// Why an air strike cannot be issued right now, or null when it can. Blocks a
|
||||
// plane that is already flying a mission, is still re-arming, or whose
|
||||
// nation cannot afford the (deliberately steep) price.
|
||||
// plane that is already flying a mission or is still re-arming, and a nation
|
||||
// whose stores cannot cover the munitions the sortie burns.
|
||||
_airStrikeBlocked(units) {
|
||||
const snapshot = this.snapshot || {};
|
||||
const totalHours = snapshot.totalHours || 0;
|
||||
const budget = (snapshot.viewerStats || {}).budget || 0;
|
||||
for (const unit of units) {
|
||||
if (unit.strikeTarget) {
|
||||
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
|
||||
@@ -404,12 +403,16 @@ export const panelMethods = {
|
||||
};
|
||||
}
|
||||
}
|
||||
const cost = (AIR_STRIKE.cost || 0) * units.length;
|
||||
if (cost > budget) {
|
||||
const prices = marketPricesFrom(this.snapshot);
|
||||
// A sortie is paid for in munitions, not money (GameState.requestAirStrike):
|
||||
// every aircraft spends steel and high-tech from the nation's stores.
|
||||
const per = RESOURCE_RULES.combatConsumption.airStrike || {};
|
||||
const neededSteel = (per.steel || 0) * units.length;
|
||||
const neededTech = (per.hightech || 0) * units.length;
|
||||
const stock = (((snapshot.viewerStats || {}).resources || {}).stock) || {};
|
||||
if (neededSteel > (stock.steel || 0) + 1e-6 || neededTech > (stock.hightech || 0) + 1e-6) {
|
||||
return {
|
||||
title: "Insufficient budget",
|
||||
message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
|
||||
title: "Insufficient munitions",
|
||||
message: "The nation's stores cannot cover this air strike's steel and high-tech.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -937,7 +940,10 @@ export const panelMethods = {
|
||||
const militaryWorks = this.map.tileImprovements.get(k);
|
||||
if (militaryWorks) {
|
||||
const proto = tileImprovementById(militaryWorks.id);
|
||||
works.push(proto ? proto.name : militaryWorks.id);
|
||||
const level = militaryWorks.level || 0;
|
||||
works.push(
|
||||
(proto ? proto.name : militaryWorks.id) + (level > 0 ? ` (level ${level})` : "")
|
||||
);
|
||||
}
|
||||
$("#tile-improvements").text(
|
||||
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
|
||||
@@ -969,19 +975,33 @@ export const panelMethods = {
|
||||
const work = queue.find(
|
||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||
);
|
||||
if (!work) return;
|
||||
const proto = tileImprovementById(work.id);
|
||||
const name = proto ? proto.name : work.id;
|
||||
const total = Math.max(work.totalHours || 0, 0.0001);
|
||||
const elapsed = work.elapsedHours || 0;
|
||||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
||||
$bar
|
||||
if (work) {
|
||||
const proto = tileImprovementById(work.id);
|
||||
const name = proto ? proto.name : work.id;
|
||||
const total = Math.max(work.totalHours || 0, 0.0001);
|
||||
const elapsed = work.elapsedHours || 0;
|
||||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
||||
$bar
|
||||
);
|
||||
}
|
||||
// A production building upgrades itself: show the work its agent has begun.
|
||||
const stats = this.snapshot.viewerStats || {};
|
||||
const building = (stats.productionBuildings || []).find(
|
||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||
);
|
||||
if (building && building.upgrading) {
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(
|
||||
`Upgrading ${building.name} to level ${(building.level || 0) + 1}` +
|
||||
` — ${building.upgradeDaysLeft || 0} days left`
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// The city panel's quick view of what is currently being produced there, so
|
||||
@@ -1372,7 +1392,7 @@ export const panelMethods = {
|
||||
const improvement = this.map.tileImprovements.get(k);
|
||||
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
||||
if (proto && proto.resource && proto.outputPerDay) {
|
||||
amounts[proto.resource] += proto.outputPerDay * multiplier;
|
||||
amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier;
|
||||
}
|
||||
return { amounts, factors, foodMultiplier };
|
||||
},
|
||||
@@ -1784,6 +1804,10 @@ export const panelMethods = {
|
||||
exchangeRates: stats.exchangeRates || [],
|
||||
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
||||
privateCash: stats.privateCash || 0,
|
||||
// Production buildings are private-sector agents of their own, held apart
|
||||
// from the regions in the Economy tab.
|
||||
privateBuildingCash: stats.privateBuildingCash || 0,
|
||||
productionBuildings: stats.productionBuildings || [],
|
||||
// Where the viewer's own currency sits: treasury, regions, foreign banks.
|
||||
currencyHoldings: stats.currencyHoldings || null,
|
||||
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
|
||||
|
||||
@@ -25,12 +25,11 @@ export const DRAG_THRESHOLD = 5;
|
||||
export const ZOOM_MIN = 0.2;
|
||||
export const ZOOM_MAX = 2.0;
|
||||
|
||||
// The camera looks north from an orthographic elevation of 70° above the
|
||||
// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the
|
||||
// east-west axis is untouched: a gentle 3D tilt that keeps the current
|
||||
// orientation. It is applied to the terrain, roads, borders, fog and ground
|
||||
// overlays; units, cities and labels stay upright on top.
|
||||
export const CAMERA_ELEVATION_DEG = 45;
|
||||
// The camera looks north from an orthographic elevation of 45° above the
|
||||
// ground. That foreshortens the north-south axis by sin(45°) ~= 0.71 while the
|
||||
// east-west axis is untouched. It is applied to the terrain, roads, borders,
|
||||
// fog and ground overlays; units, cities and labels stay upright on top.
|
||||
const CAMERA_ELEVATION_DEG = 45;
|
||||
export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180);
|
||||
|
||||
// How far the tallest terrain (normalised height 1, see map_view/heightmap.js)
|
||||
@@ -105,10 +104,8 @@ export function politicalMode(zoom) {
|
||||
}
|
||||
|
||||
// A battle spreads the two stacks to either side of the tile centre, with the
|
||||
// battle icon between them. Side offset is in pixels; step is the spacing
|
||||
// between the (half-size) units of one stack.
|
||||
// battle icon between them. The side offset is in pixels.
|
||||
export const BATTLE_SIDE_OFFSET = 15;
|
||||
export const BATTLE_STACK_STEP = 9;
|
||||
|
||||
// The detailed view is built from square blocks of tiles. Only the blocks that
|
||||
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
|
||||
|
||||
@@ -89,8 +89,8 @@ export const entityMethods = {
|
||||
this._warfareSignature = signature;
|
||||
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
|
||||
this.tileImprovements = new Map();
|
||||
for (const [x, y, id, owner, hp] of improvements) {
|
||||
this.tileImprovements.set(key(x, y), { id, owner, hp });
|
||||
for (const [x, y, id, owner, hp, level] of improvements) {
|
||||
this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 });
|
||||
}
|
||||
this._renderWarfare();
|
||||
},
|
||||
|
||||
@@ -875,12 +875,6 @@ export class GLMapRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
// Fog has no texture, so it uses a compact position/height/colour vertex. That
|
||||
// keeps the buffer (re-uploaded whenever visibility changes) small.
|
||||
_pushSimple(arr, x, y, height, colour) {
|
||||
arr.push(x, y, height || 0, colour[0], colour[1], colour[2], colour[3]);
|
||||
}
|
||||
|
||||
// Adds a convex quad given its four vertices (with per-vertex heights) as two
|
||||
// triangles. Wind order does not matter: we never cull faces.
|
||||
_quad(arr, corners, colour) {
|
||||
|
||||
+78
-17
@@ -443,15 +443,18 @@ export class NationModal {
|
||||
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
|
||||
}
|
||||
|
||||
// The aggregate private-sector cash the nation's regions hold, and each
|
||||
// region's own reserve.
|
||||
// The aggregate private-sector cash the nation's regions and its independent
|
||||
// production buildings hold, each listed on its own row.
|
||||
_renderPrivateSector(money) {
|
||||
if (!this.$economySummary || !this.$economySummary.length) return;
|
||||
const cash = money.privateCash || 0;
|
||||
const buildingCash = money.privateBuildingCash || 0;
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
this.$economySub.text(
|
||||
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` +
|
||||
`${(money.currency && money.currency.name) || ""}`
|
||||
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` +
|
||||
` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` +
|
||||
` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` +
|
||||
` · ${(money.currency && money.currency.name) || ""}`
|
||||
);
|
||||
}
|
||||
this.$economySummary.empty();
|
||||
@@ -462,36 +465,94 @@ export class NationModal {
|
||||
)
|
||||
);
|
||||
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
|
||||
if (cities.length === 0) return;
|
||||
if (cities.length > 0) {
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $head = $("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Region"))
|
||||
.append($("<th class='numeric'></th>").text("Private cash"))
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
let total = 0;
|
||||
for (const city of cities) {
|
||||
total += city.cashValue || 0;
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(city.name))
|
||||
.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
const $foot = $("<tfoot></tfoot>").append(
|
||||
$("<tr class='budget-total'></tr>")
|
||||
.append($("<td></td>").text("Total"))
|
||||
.append($("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
||||
))
|
||||
);
|
||||
$table.append($foot);
|
||||
this.$economySummary.append($table);
|
||||
}
|
||||
this._renderProductionBuildings(money.productionBuildings || []);
|
||||
}
|
||||
|
||||
// Production buildings are private-sector agents too, but a separate class
|
||||
// from the regions: each keeps its own cash and reinvests it in upgrades.
|
||||
_renderProductionBuildings(buildings) {
|
||||
if (!this.$economySummary || buildings.length === 0) return;
|
||||
this.$economySummary.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"Production buildings are independent private agents: they keep what " +
|
||||
"their output sells for, and spend it upgrading when demand has grown."
|
||||
)
|
||||
);
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $head = $("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Region"))
|
||||
.append($("<th></th>").text("Production building"))
|
||||
.append($("<th></th>").text("Output / day"))
|
||||
.append($("<th class='numeric'></th>").text("Level"))
|
||||
.append($("<th class='numeric'></th>").text("Private cash"))
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
let total = 0;
|
||||
for (const city of cities) {
|
||||
total += city.cashValue || 0;
|
||||
for (const building of buildings) {
|
||||
total += building.cash || 0;
|
||||
const level = building.level || 0;
|
||||
const resource = resourceById(building.resource);
|
||||
const $output = resource && building.outputPerDay
|
||||
? $("<td></td>").text(formatResourceAmount(building.resource, building.outputPerDay))
|
||||
: $("<td></td>").text("—");
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(city.name))
|
||||
.append($("<td></td>").text(building.name))
|
||||
.append($output)
|
||||
.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
|
||||
level + (building.upgrading ? " ↑" : "")
|
||||
)
|
||||
)
|
||||
.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
const $foot = $("<tfoot></tfoot>").append(
|
||||
$("<tr class='budget-total'></tr>")
|
||||
.append($("<td></td>").text("Total"))
|
||||
.append($("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
||||
))
|
||||
$table.append(
|
||||
$("<tfoot></tfoot>").append(
|
||||
$("<tr class='budget-total'></tr>")
|
||||
.append($("<td colspan='3'></td>").text("Total"))
|
||||
.append($("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
||||
))
|
||||
)
|
||||
);
|
||||
$table.append($foot);
|
||||
this.$economySummary.append($table);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ import { CIVILISATIONS, MAP_CONFIG } from "../shared/data.js";
|
||||
import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
|
||||
import {
|
||||
cityEnergyPerDay,
|
||||
foodNeedPerDay,
|
||||
steelNeedPerDay,
|
||||
luxuryNeedPerDay,
|
||||
} from "../shared/resources.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ export const CITY_BOMBARD = {
|
||||
|
||||
// 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
|
||||
// front so bombing cannot be spammed for free.
|
||||
// Each attacking aircraft burns munitions from its nation's stores (see
|
||||
// RESOURCE_RULES.combatConsumption.airStrike), charged up front so bombing
|
||||
// cannot be spammed for free.
|
||||
export const AIR_STRIKE = {
|
||||
cost: 10_000_000,
|
||||
cooldownHours: 48,
|
||||
};
|
||||
|
||||
|
||||
@@ -84,8 +84,6 @@ export const PROPAGANDA = {
|
||||
|
||||
export const CAMPAIGN_ETHNICITY = "ethnicity";
|
||||
export const CAMPAIGN_GOVERNMENT = "government";
|
||||
export const CAMPAIGN_WARM = 1;
|
||||
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.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// Energy is stored and moved as kWh. These keep the catalogue readable.
|
||||
export const KWH_PER_MWH = 1_000;
|
||||
export const KWH_PER_GWH = 1_000_000;
|
||||
export const KWH_PER_TWH = 1_000_000_000;
|
||||
|
||||
// One tonne of any material counts as this much energy on the wire, so material
|
||||
// deliveries and energy deliveries share one transport-cost formula.
|
||||
@@ -134,6 +133,11 @@ export const RESOURCE_RULES = {
|
||||
|
||||
// Energy the material producers consume to make one canonical unit.
|
||||
energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne
|
||||
// Food a region can force from energy beyond its own harvest, in greenhouses
|
||||
// and synthesis. The energy for `x` extra tonnes a day is this coefficient
|
||||
// times `x^2`, so each further tonne costs more energy than the last and a
|
||||
// region grows only while its marginal energy beats the market price of food.
|
||||
foodSynthesisQuadratic: 20, // kWh per tonne squared
|
||||
energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne
|
||||
energyPerLuxuryCarat: {
|
||||
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
|
||||
@@ -412,6 +416,56 @@ for (const building of RESOURCE_BUILDINGS) {
|
||||
if (building.takesCombatDamage === undefined) building.takesCombatDamage = true;
|
||||
}
|
||||
|
||||
// Every resource building -- a material producer or a power plant -- is an
|
||||
// independent private-sector agent. It keeps its own cash from what it sells,
|
||||
// tries to grow when demand for its output has been rising, and spends that
|
||||
// cash to upgrade itself. Power is a paid flow: the plants are paid for the
|
||||
// power the grid's consumers draw, and a plant that keeps selling out expands.
|
||||
//
|
||||
// An upgrade to level u raises throughput exponentially (`outputPerDay` times
|
||||
// `alpha^u`) but the extra output costs more power: the operating input grows
|
||||
// linearly (`1 + inputPerLevel*u`). Each further level costs `costAlpha^u` more
|
||||
// steel and high-tech, so the benefit compounds slower than the bill.
|
||||
export const RESOURCE_BUILDING_UPGRADE = {
|
||||
alpha: 1.1,
|
||||
inputPerLevel: 1,
|
||||
costAlpha: 1.1,
|
||||
// A ceiling on the compounding, so a very old building cannot run away.
|
||||
maxLevel: 12,
|
||||
// How many days of sales the agent watches for the demand trend, and how much
|
||||
// its recent average must beat its older one before it commits to an upgrade.
|
||||
salesWindow: 6,
|
||||
demandGrowth: 0,
|
||||
// A producer that sells at least this share of what it makes is demand-bound
|
||||
// (it sells out), so it grows even when its sales look flat.
|
||||
sellThrough: 0.9,
|
||||
// The cash headroom over the quoted material bill an agent wants before it
|
||||
// starts, so a price swing cannot leave an upgrade half-bought.
|
||||
cashMargin: 1.15,
|
||||
};
|
||||
|
||||
// A production building's output per day at upgrade `level`, exponential in the
|
||||
// level. Shared by the simulation and the browser so both quote the same figure.
|
||||
export function resourceBuildingOutputAt(proto, level) {
|
||||
const base = (proto && proto.outputPerDay) || 0;
|
||||
return base * Math.pow(RESOURCE_BUILDING_UPGRADE.alpha, Math.max(0, level || 0));
|
||||
}
|
||||
|
||||
// A production building's operating input at upgrade `level`: the plant's fuel
|
||||
// or a converter's energy, growing linearly with the level. `field` is the
|
||||
// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`).
|
||||
export function resourceBuildingInputAt(proto, level, field = "energyPerDay") {
|
||||
const base = (proto && proto[field]) || 0;
|
||||
return base * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel * Math.max(0, level || 0));
|
||||
}
|
||||
|
||||
// The money an agent pays to reach the next level from `level`: the build cost
|
||||
// compounded by `costAlpha` per level already gained.
|
||||
export function resourceBuildingUpgradeMoneyCost(proto, level) {
|
||||
const base = (proto && proto.buildCost) || 0;
|
||||
return base * Math.pow(RESOURCE_BUILDING_UPGRADE.costAlpha, Math.max(0, level || 0));
|
||||
}
|
||||
|
||||
export const RESOURCE_BUILDING_BY_ID = Object.fromEntries(
|
||||
RESOURCE_BUILDINGS.map((building) => [building.id, building])
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ import { serializationMethods } from "./game_state/serialization.js";
|
||||
import { monthlyMethods } from "./game_state/monthly.js";
|
||||
import { warfareMethods } from "./game_state/warfare.js";
|
||||
import { resourceMethods } from "./game_state/resources.js";
|
||||
import { industryMethods } from "./game_state/industry.js";
|
||||
import { budgetMethods } from "./game_state/budget.js";
|
||||
import { moneyMethods } from "./game_state/money.js";
|
||||
import { taxMethods } from "./game_state/taxes.js";
|
||||
@@ -118,6 +119,9 @@ export class GameState {
|
||||
this.tileImprovementOwner = new Map();
|
||||
this.tileImprovementHp = new Map();
|
||||
this.tileWorks = new Map();
|
||||
// Production buildings as independent private-sector agents, keyed by tile:
|
||||
// each keeps its own cash, upgrade level and sales history.
|
||||
this.buildingAgents = 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.
|
||||
@@ -384,6 +388,7 @@ export class GameState {
|
||||
}
|
||||
this._tickBuildings();
|
||||
this._tickTileWorks();
|
||||
this._tickIndustry();
|
||||
this._tickEmbark();
|
||||
this._tickCoastalCannons();
|
||||
// A unit no longer sharing a tile with an enemy is not an invader any more.
|
||||
@@ -413,6 +418,7 @@ Object.assign(
|
||||
monthlyMethods,
|
||||
warfareMethods,
|
||||
resourceMethods,
|
||||
industryMethods,
|
||||
budgetMethods,
|
||||
moneyMethods,
|
||||
taxMethods
|
||||
|
||||
@@ -288,7 +288,7 @@ export const airMethods = {
|
||||
// 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) {
|
||||
_applyStrikeDamage(target, damage) {
|
||||
const proto = this.unitProto(target);
|
||||
const alone = this.unitsAt(target.coords).length <= 1;
|
||||
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
|
||||
@@ -420,7 +420,7 @@ export const airMethods = {
|
||||
const target = this._strikeTargetAt(goal, unit.civ);
|
||||
if (target) {
|
||||
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
|
||||
this._applyStrikeDamage(target, damage, this.unitProto(unit));
|
||||
this._applyStrikeDamage(target, damage);
|
||||
} else {
|
||||
const city = this._strikeCityAt(goal, unit.civ);
|
||||
if (city) {
|
||||
@@ -471,7 +471,7 @@ export const airMethods = {
|
||||
if (damage.size === 0) return;
|
||||
const destroyed = [];
|
||||
for (const [target, amount] of damage) {
|
||||
if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target);
|
||||
if (this._applyStrikeDamage(target, amount)) destroyed.push(target);
|
||||
}
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
@@ -133,7 +133,7 @@ export const economyMethods = {
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
if (isResourceTileBuilding(proto)) {
|
||||
daily += (proto.outputPerDay || 0) * this.getResourcePrice(proto.resource);
|
||||
daily += this.resourceBuildingOutputAt(k, proto) * this.getResourcePrice(proto.resource);
|
||||
}
|
||||
}
|
||||
return daily * DAYS_PER_YEAR;
|
||||
@@ -522,15 +522,6 @@ export const economyMethods = {
|
||||
return groups.sort((a, b) => b.amount - a.amount);
|
||||
},
|
||||
|
||||
_buildingUpkeepSources(civ) {
|
||||
return this._buildingUpkeepGroups(civ).map((group) => ({
|
||||
label: group.label,
|
||||
kind: "building",
|
||||
amount: group.amount,
|
||||
resources: group.resources,
|
||||
}));
|
||||
},
|
||||
|
||||
_unitUpkeepSources(civ) {
|
||||
const sources = new Map();
|
||||
const battle = this._battleUnitIds();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// Production buildings as independent private-sector agents. Every resource
|
||||
// building -- a steel mill, a mine, a chip foundry, a power plant -- is not
|
||||
// merely a region's asset: it keeps its own cash from what it sells, watches
|
||||
// how its own demand moves, and reinvests that cash in upgrading itself. A
|
||||
// converter makes only what it can sell and the grid pays its plants for the
|
||||
// power it delivers (see `_settleGridPower`), so no agent is forced to run at
|
||||
// a permanent loss.
|
||||
//
|
||||
// The daily tick rolls each agent's sales into a short window, then lets an
|
||||
// agent whose recent sales beat its older ones -- or that is selling out -- buy
|
||||
// the steel and high-tech for its next level and start the work. An upgrade
|
||||
// raises throughput exponentially but the extra output costs power linearly
|
||||
// (see RESOURCE_BUILDING_UPGRADE in shared/data/resources.js), and an agent may
|
||||
// only have one upgrade under way at a time.
|
||||
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.js";
|
||||
import {
|
||||
RESOURCE_BUILDING_UPGRADE,
|
||||
resourceBuildingOutputAt,
|
||||
resourceBuildingInputAt,
|
||||
resourceBuildingUpgradeMoneyCost,
|
||||
tileImprovementById,
|
||||
isResourceTileBuilding,
|
||||
} from "../data.js";
|
||||
import { HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
function emptyBuildingAgent() {
|
||||
return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null };
|
||||
}
|
||||
|
||||
export const industryMethods = {
|
||||
// ------------------------------------------------------- agent state --
|
||||
|
||||
// Every resource building trades: a material producer sells its good, a
|
||||
// power plant sells the power its grid delivers.
|
||||
_isProductionAgent(proto) {
|
||||
return isResourceTileBuilding(proto);
|
||||
},
|
||||
|
||||
_ensureBuildingAgent(k) {
|
||||
if (!this.buildingAgents) this.buildingAgents = new Map();
|
||||
let agent = this.buildingAgents.get(k);
|
||||
if (!agent) {
|
||||
agent = emptyBuildingAgent();
|
||||
this.buildingAgents.set(k, agent);
|
||||
}
|
||||
return agent;
|
||||
},
|
||||
|
||||
// Drops the agent of a tile whose building is gone.
|
||||
_removeBuildingAgent(k) {
|
||||
if (this.buildingAgents) this.buildingAgents.delete(k);
|
||||
},
|
||||
|
||||
buildingAgentAt(coords) {
|
||||
if (!this.buildingAgents) return null;
|
||||
return this.buildingAgents.get(key(coords.x, coords.y)) || null;
|
||||
},
|
||||
|
||||
resourceBuildingLevel(k) {
|
||||
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
|
||||
return agent ? agent.level : 0;
|
||||
},
|
||||
|
||||
// The output per day of the building on `k`, compounded by its level.
|
||||
resourceBuildingOutputAt(k, proto) {
|
||||
return resourceBuildingOutputAt(proto, this.resourceBuildingLevel(k));
|
||||
},
|
||||
|
||||
// The operating input of the building on `k` at its level. `field` names the
|
||||
// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`).
|
||||
resourceBuildingInputAt(k, proto, field = "energyPerDay") {
|
||||
return resourceBuildingInputAt(proto, this.resourceBuildingLevel(k), field);
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------ money --
|
||||
|
||||
// A buyer's payment reaches the producing building, into its own cash, in the
|
||||
// currency of the nation it belongs to whatever currency it was paid in.
|
||||
_creditBuildingCash(k, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) return;
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const value = amount * this.currencyValueOf(code) / this.currencyValue(owner);
|
||||
agent.cash = (agent.cash || 0) + value;
|
||||
},
|
||||
|
||||
// Records that a production agent sold `qty` of its output during the day,
|
||||
// feeding the demand trend that decides whether it grows.
|
||||
_recordBuildingSale(k, qty) {
|
||||
if (!(qty > 0)) return;
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
agent.salesToday = (agent.salesToday || 0) + qty;
|
||||
},
|
||||
|
||||
// A payer that draws on one production building's own cash.
|
||||
_buildingPayer(k) {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const payer = { total: 0 };
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
// An agent never mints money: a bill it cannot cover leaves it overdrawn,
|
||||
// to be paid off from the next sales.
|
||||
agent.cash = (agent.cash || 0) - cost;
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
},
|
||||
|
||||
// Charges one production building for something the grid delivered to it,
|
||||
// from its own cash. Like `_buildingPayer`, it may go overdrawn rather than
|
||||
// fail, so the money a supplier receives is real.
|
||||
_chargeBuildingCash(k, code, cost) {
|
||||
if (!(cost > 0)) return;
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) return;
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
const value = cost * this.currencyValueOf(code) / this.currencyValue(owner);
|
||||
agent.cash = (agent.cash || 0) - value;
|
||||
},
|
||||
|
||||
// Pays a grid's plants for `kwh` of power worth `money`, split by how much
|
||||
// each contributed, and credits each with the sale its demand trend reads.
|
||||
_payGridProducers(component, money, kwh) {
|
||||
const producers = (component && component.producers) || [];
|
||||
let total = 0;
|
||||
for (const producer of producers) total += producer.output || 0;
|
||||
if (!(total > 0)) {
|
||||
if (money > 0 && component.civ >= 0) this._creditRegions(component.civ, money);
|
||||
return;
|
||||
}
|
||||
const code = this.currencyOf(component.civ).code;
|
||||
for (const producer of producers) {
|
||||
const share = (producer.output || 0) / total;
|
||||
if (money > 0) this._creditBuildingCash(producer.k, code, money * share);
|
||||
if (kwh > 0) this._recordBuildingSale(producer.k, kwh * share);
|
||||
}
|
||||
},
|
||||
|
||||
// Everything the nation's production buildings hold, in the nation's currency.
|
||||
getPrivateBuildingCash(civ) {
|
||||
if (!this.buildingAgents) return 0;
|
||||
let total = 0;
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
total += agent.cash || 0;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// A nation's production agents as plain objects, for the Economy tab's
|
||||
// separate private-sector row and the tile panel.
|
||||
getProductionAgents(civ) {
|
||||
const result = [];
|
||||
if (!this.buildingAgents) return result;
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
if (!this._isProductionAgent(proto)) continue;
|
||||
const coords = parseKey(k);
|
||||
result.push({
|
||||
coords: [coords.x, coords.y],
|
||||
name: proto.name,
|
||||
resource: proto.resource,
|
||||
level: agent.level,
|
||||
cash: agent.cash || 0,
|
||||
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
|
||||
upgrading: !!agent.upgrading,
|
||||
upgradeDaysLeft: agent.upgrading
|
||||
? Math.max(0, Math.ceil((agent.upgrading.totalHours - agent.upgrading.elapsedHours) / HOURS_PER_DAY))
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// The steel and high-tech one upgrade level needs, scaled off its money cost
|
||||
// like any other construction. Only an advanced producer needs the high-tech.
|
||||
_upgradeMaterialCost(proto, level) {
|
||||
const money = resourceBuildingUpgradeMoneyCost(proto, level);
|
||||
return {
|
||||
steel: money * RESOURCE_RULES.steelPerBudgetEuro,
|
||||
hightech: proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
// How much of its capacity a converter should run at. It runs flat out while
|
||||
// its store is thin -- so it always keeps up with demand -- and only eases off
|
||||
// once it is sitting on more stock than it has been selling, making roughly
|
||||
// what it sells so it never burns power on goods it cannot move. A brand-new
|
||||
// building has no history and runs flat out.
|
||||
_converterProductionScale(k, proto, agent) {
|
||||
const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0);
|
||||
if (!(capacity > 0)) return 1;
|
||||
const sales = (agent && agent.sales) || [];
|
||||
let expected = capacity;
|
||||
if (sales.length > 0) {
|
||||
const half = Math.max(1, Math.floor(sales.length / 2));
|
||||
const recent = sales.slice(-half);
|
||||
expected = recent.reduce((sum, value) => sum + value, 0) / recent.length;
|
||||
}
|
||||
const target = Math.max(capacity, expected * 2);
|
||||
const store = this.resourceStock ? this.resourceStock.get(k) : null;
|
||||
const stock = (store || {})[proto.resource] || 0;
|
||||
if (stock <= target) return 1;
|
||||
// Overstocked: make only what has been selling, but never switch off.
|
||||
return Math.max(0.1, Math.min(1, expected / capacity));
|
||||
},
|
||||
|
||||
// What the next level's materials would cost at today's delivered market
|
||||
// price, in the agent's national currency. The delivered cost folds in a
|
||||
// nominal two-tile haul, so the quoted bill is not beaten by a distant
|
||||
// supplier's freight.
|
||||
_upgradeMaterialValue(civ, materials) {
|
||||
const value = materials.steel * this._marketUnitCost("steel") +
|
||||
materials.hightech * this._marketUnitCost("hightech");
|
||||
return value / this.currencyValue(civ);
|
||||
},
|
||||
|
||||
// ------------------------------------------------------- daily tick --
|
||||
|
||||
// One hour of every agent's upgrade, and once a day the demand read that may
|
||||
// start a new one.
|
||||
_tickIndustry() {
|
||||
if (!this.buildingAgents || this.buildingAgents.size === 0) return;
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (!agent.upgrading) continue;
|
||||
agent.upgrading.elapsedHours += 1;
|
||||
if (agent.upgrading.elapsedHours >= agent.upgrading.totalHours) {
|
||||
this._completeBuildingUpgrade(k, agent);
|
||||
}
|
||||
}
|
||||
if (this.totalHours % HOURS_PER_DAY === 0) this._tickBuildingDecisions();
|
||||
},
|
||||
|
||||
_tickBuildingDecisions() {
|
||||
// Roll today's sales into the trend window before reading it.
|
||||
for (const agent of this.buildingAgents.values()) {
|
||||
agent.sales.push(agent.salesToday || 0);
|
||||
if (agent.sales.length > RESOURCE_BUILDING_UPGRADE.salesWindow) agent.sales.shift();
|
||||
agent.salesToday = 0;
|
||||
}
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (agent.upgrading) continue;
|
||||
if (agent.level >= RESOURCE_BUILDING_UPGRADE.maxLevel) continue;
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
if (!this._isProductionAgent(proto)) continue;
|
||||
if (!this._buildingDemandGrowing(agent, this.resourceBuildingOutputAt(k, proto))) {
|
||||
continue;
|
||||
}
|
||||
this._startBuildingUpgrade(k, proto, agent);
|
||||
}
|
||||
},
|
||||
|
||||
// Whether the agent should grow: its recent sales have to beat its older ones,
|
||||
// or it has to be selling out (demand at least matching what it can make).
|
||||
_buildingDemandGrowing(agent, capacity = 0) {
|
||||
const sales = agent.sales || [];
|
||||
if (sales.length < 2) return false;
|
||||
const half = Math.floor(sales.length / 2);
|
||||
const older = sales.slice(0, half);
|
||||
const recent = sales.slice(half);
|
||||
const average = (list) => list.reduce((sum, value) => sum + value, 0) / list.length;
|
||||
const before = average(older);
|
||||
const now = average(recent);
|
||||
if (now > before * (1 + RESOURCE_BUILDING_UPGRADE.demandGrowth)) return true;
|
||||
return capacity > 0 &&
|
||||
now >= capacity * RESOURCE_BUILDING_UPGRADE.sellThrough &&
|
||||
now >= before;
|
||||
},
|
||||
|
||||
// Commit an agent to its next level: buy the materials from its own stores and
|
||||
// the reachable market with its own cash, then start the work. Only one
|
||||
// upgrade may run at a time.
|
||||
_startBuildingUpgrade(k, proto, agent) {
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) return false;
|
||||
const level = agent.level;
|
||||
const materials = this._upgradeMaterialCost(proto, level);
|
||||
const quoted = this._upgradeMaterialValue(owner, materials) *
|
||||
RESOURCE_BUILDING_UPGRADE.cashMargin;
|
||||
if ((agent.cash || 0) < quoted) return false;
|
||||
const coords = parseKey(k);
|
||||
if (this._buildingMaterialAvailability(owner, coords, "steel") < materials.steel) return false;
|
||||
if (materials.hightech > 0 &&
|
||||
this._buildingMaterialAvailability(owner, coords, "hightech") < materials.hightech) {
|
||||
return false;
|
||||
}
|
||||
if (!this._buyBuildingMaterials(k, coords, owner, materials)) return false;
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(owner));
|
||||
const money = resourceBuildingUpgradeMoneyCost(proto, level);
|
||||
const totalHours = capacity > 0
|
||||
? Math.max(1, (money / capacity) * this.constructionSpeedMultiplier(owner))
|
||||
: 1;
|
||||
agent.upgrading = { elapsedHours: 0, totalHours, targetLevel: level + 1 };
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
_completeBuildingUpgrade(k, agent) {
|
||||
agent.level = agent.upgrading ? agent.upgrading.targetLevel : agent.level + 1;
|
||||
agent.upgrading = null;
|
||||
// The extra output lifts the region's GDP, so the cached production is stale.
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._touchModifiers();
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// The amount of `id` an agent could lay hands on for an upgrade: its own
|
||||
// store for free, the nation's city stores, and the nearby producers' surplus.
|
||||
_buildingMaterialAvailability(civ, coords, id) {
|
||||
const k = key(coords.x, coords.y);
|
||||
let available = (this.resourceStock.get(k) || {})[id] || 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
available += this.getCityResourceStock(city)[id] || 0;
|
||||
}
|
||||
const city = this._nearestCity(civ, coords);
|
||||
if (!city) return available;
|
||||
const nodes = this._resourceNodes();
|
||||
const allowSea = this._cityMarketAccess(city);
|
||||
for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) {
|
||||
if (supplier.kind !== "building") continue;
|
||||
if (key(supplier.coords.x, supplier.coords.y) === k) continue;
|
||||
available += this._nodeSurplus(supplier, id);
|
||||
}
|
||||
return available;
|
||||
},
|
||||
|
||||
// Spends the agent's cash on the upgrade's materials: its own store first
|
||||
// (already its own), then the nation's regions, then the reachable market.
|
||||
_buyBuildingMaterials(k, coords, civ, materials) {
|
||||
const own = this._ensureResourceStock(k);
|
||||
const ownSteel = Math.min(materials.steel, own.steel || 0);
|
||||
const ownTech = Math.min(materials.hightech, own.hightech || 0);
|
||||
if (ownSteel > 0) own.steel -= ownSteel;
|
||||
if (ownTech > 0) own.hightech -= ownTech;
|
||||
let needSteel = materials.steel - ownSteel;
|
||||
let needTech = materials.hightech - ownTech;
|
||||
const payer = this._buildingPayer(k);
|
||||
if (needSteel > 0) {
|
||||
needSteel -= this._drawFromNearest(civ, coords, { steel: needSteel, hightech: 0 }, payer).steel;
|
||||
}
|
||||
if (needTech > 0) {
|
||||
needTech -= this._drawFromNearest(civ, coords, { steel: 0, hightech: needTech }, payer).hightech;
|
||||
}
|
||||
if (needSteel > 0 || needTech > 0) {
|
||||
const city = this._nearestCity(civ, coords);
|
||||
if (city) {
|
||||
const nodes = this._resourceNodes();
|
||||
const allowSea = this._cityMarketAccess(city);
|
||||
const haul = { steel: 0, hightech: 0 };
|
||||
if (needSteel > 0) {
|
||||
needSteel -= this._procureFromNeighbours(
|
||||
city, "steel", needSteel, haul, nodes, { payer, allowSea }
|
||||
);
|
||||
}
|
||||
if (needTech > 0) {
|
||||
needTech -= this._procureFromNeighbours(
|
||||
city, "hightech", needTech, haul, nodes, { payer, allowSea }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return needSteel <= 1e-6 && needTech <= 1e-6;
|
||||
},
|
||||
};
|
||||
+15
-12
@@ -96,18 +96,6 @@ export const moneyMethods = {
|
||||
|
||||
// --------------------------------------------------- region cash --
|
||||
|
||||
// Takes `amount` of one currency out of a region's basket. Returns false (and
|
||||
// charges nothing) when the region does not hold enough.
|
||||
_debitRegionCash(city, code, amount) {
|
||||
if (!(amount > 0)) return true;
|
||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
||||
if (!basket) return false;
|
||||
const held = basket.get(code) || 0;
|
||||
if (held + 1e-9 < amount) return false;
|
||||
basket.set(code, held - amount);
|
||||
return true;
|
||||
},
|
||||
|
||||
_creditRegionCash(city, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
||||
@@ -302,6 +290,8 @@ export const moneyMethods = {
|
||||
// An overdrawn treasury is a claim on the bank, not circulating money.
|
||||
let amount = Math.max(0, this.getBudget(civ) || 0);
|
||||
for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 0;
|
||||
// The production buildings are private agents too, holding their own cash.
|
||||
amount += this.getPrivateBuildingCash(civ);
|
||||
for (const reserves of this.centralBankReserves.values()) {
|
||||
amount += reserves.get(code) || 0;
|
||||
}
|
||||
@@ -405,6 +395,19 @@ export const moneyMethods = {
|
||||
banks.push({ civ: c, name: host ? host.name : "", amount });
|
||||
bankTotal += amount;
|
||||
}
|
||||
// The nation's production buildings hold their own cash, in the national
|
||||
// currency, alongside the regions.
|
||||
const buildingCash = this.getPrivateBuildingCash(civ);
|
||||
if (buildingCash !== 0) {
|
||||
const host = this.civilisations[civ];
|
||||
own.push({
|
||||
name: "Production buildings",
|
||||
civ,
|
||||
civName: host ? host.name : "",
|
||||
amount: buildingCash,
|
||||
});
|
||||
ownTotal += buildingCash;
|
||||
}
|
||||
const budget = this.getBudget(civ) || 0;
|
||||
const treasury = Math.max(0, budget);
|
||||
const treasuryDebt = Math.max(0, -budget);
|
||||
|
||||
@@ -378,19 +378,11 @@ export const politicsMethods = {
|
||||
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));
|
||||
},
|
||||
|
||||
+205
-52
@@ -19,6 +19,7 @@ import {
|
||||
FAMINE,
|
||||
tileImprovementById,
|
||||
isResourceTileBuilding,
|
||||
resourceById,
|
||||
MONEY,
|
||||
} from "../data.js";
|
||||
import {
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
luxuryNeedPerDay,
|
||||
foodMonthlyTarget,
|
||||
producerEnergyPerDay,
|
||||
foodSynthesisEnergy,
|
||||
synthesisedFoodFor,
|
||||
deliveryEnergyForResource,
|
||||
nextMarketPrice,
|
||||
upkeepResources,
|
||||
protoUpkeep,
|
||||
constructionResourceCost as sharedConstructionResourceCost,
|
||||
} from "../resources.js";
|
||||
@@ -314,6 +316,7 @@ export const resourceMethods = {
|
||||
this.tileImprovements.set(k, proto.id);
|
||||
this.tileImprovementOwner.set(k, civ);
|
||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
|
||||
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
|
||||
// A work beside the network gets a spur; one already on it needs none.
|
||||
if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
|
||||
this.roads.add(k);
|
||||
@@ -375,7 +378,7 @@ export const resourceMethods = {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
|
||||
made[proto.resource] = (made[proto.resource] || 0) + proto.outputPerDay;
|
||||
made[proto.resource] = (made[proto.resource] || 0) + this.resourceBuildingOutputAt(k, proto);
|
||||
}
|
||||
return made;
|
||||
},
|
||||
@@ -589,10 +592,17 @@ export const resourceMethods = {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!isResourceTileBuilding(proto)) continue;
|
||||
if (proto.resource === "energy") energySupply += proto.outputPerDay;
|
||||
else production[proto.resource] += proto.outputPerDay;
|
||||
if (proto.resource === "energy") {
|
||||
energySupply += this.resourceBuildingOutputAt(k, proto);
|
||||
} else {
|
||||
// A converter makes only what it can sell, so report the throttled
|
||||
// figure the daily tick used rather than the nameplate capacity.
|
||||
const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1;
|
||||
production[proto.resource] += this.resourceBuildingOutputAt(k, proto) * throttle;
|
||||
}
|
||||
}
|
||||
// The land's harvest is part of what the nation produces.
|
||||
// The land's harvest is part of what the nation produces, and so is the
|
||||
// extra food its regions force from energy when the harvest falls short.
|
||||
const foodMultipliers = new Map();
|
||||
for (const coords of this._territoryByCiv.get(civ) || []) {
|
||||
const k = key(coords.x, coords.y);
|
||||
@@ -608,6 +618,12 @@ export const resourceMethods = {
|
||||
}
|
||||
production.food += tileFoodOutput(this.tiles[k]) * multiplier;
|
||||
}
|
||||
if (this._foodSynthesis) {
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
production.food += this._foodSynthesis.get(city.id) || 0;
|
||||
}
|
||||
}
|
||||
const upkeep = this.getCivUpkeepResources(civ);
|
||||
// What the nation actually consumes in a day: the people's needs plus the
|
||||
// materials its buildings, units, works and transport wear out. Energy is a
|
||||
@@ -1198,6 +1214,9 @@ export const resourceMethods = {
|
||||
// charged to the tile's grid component.
|
||||
const pendingFood = [];
|
||||
const foodMultipliers = new Map();
|
||||
// The land's harvest per region, so the synthesis pass below knows who is
|
||||
// short of what its people eat.
|
||||
const regionFood = new Map();
|
||||
for (const k in this.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (!this._isLand(coords)) continue;
|
||||
@@ -1212,6 +1231,7 @@ export const resourceMethods = {
|
||||
}
|
||||
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
|
||||
if (produced <= 0) continue;
|
||||
regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced);
|
||||
pendingFood.push({
|
||||
cityId: city.id,
|
||||
coords: city.coords,
|
||||
@@ -1219,7 +1239,50 @@ export const resourceMethods = {
|
||||
amount: produced,
|
||||
});
|
||||
const component = ledger.get(components.get(k));
|
||||
if (component) component.foodEnergy += producerEnergyPerDay("food", produced);
|
||||
if (component) {
|
||||
const foodPower = producerEnergyPerDay("food", produced);
|
||||
component.foodEnergy += foodPower;
|
||||
component.foodConsumers.push({ city, amount: foodPower });
|
||||
}
|
||||
}
|
||||
|
||||
// A region whose own harvest falls short of what its people eat can force
|
||||
// extra food from energy -- greenhouses and synthesis. Growing is cheapest
|
||||
// for the first tonnes, so the region grows while the marginal energy for
|
||||
// another tonne beats buying, then completes with the dearer option: what
|
||||
// the reachable market cannot actually supply, it grows itself.
|
||||
const foodPrice = this.getResourcePrice("food");
|
||||
const energyPrice = this.getResourcePrice("energy");
|
||||
const nodes = this._resourceNodes();
|
||||
this._foodSynthesis = new Map();
|
||||
for (const city of this.cities) {
|
||||
const produced = regionFood.get(city.id) || 0;
|
||||
const need = foodNeedPerDay(this.getCityEconomy(city).population);
|
||||
const shortfall = need - produced;
|
||||
if (!(shortfall > 0)) continue;
|
||||
const allowSea = this._cityMarketAccess(city) &&
|
||||
!this._isEncircled(city.coords, city.civ);
|
||||
// The food the region can actually buy: the reachable surplus. A distant
|
||||
// island reaches none, so it must grow every tonne itself.
|
||||
let buyable = 0;
|
||||
for (const supplier of this._nearbySuppliers(city, "food", nodes, allowSea)) {
|
||||
buyable += this._nodeSurplus(supplier, "food");
|
||||
}
|
||||
const extra = synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable);
|
||||
if (!(extra > 0)) continue;
|
||||
const componentId = components.get(key(city.coords.x, city.coords.y));
|
||||
const component = ledger.get(componentId);
|
||||
if (!component) continue;
|
||||
const energy = foodSynthesisEnergy(extra);
|
||||
component.foodEnergy += energy;
|
||||
component.foodConsumers.push({ city, amount: energy });
|
||||
pendingFood.push({
|
||||
cityId: city.id,
|
||||
coords: city.coords,
|
||||
component: componentId,
|
||||
amount: extra,
|
||||
});
|
||||
this._foodSynthesis.set(city.id, extra);
|
||||
}
|
||||
|
||||
// Settle every connected grid: what the plants make against what the cities
|
||||
@@ -1234,27 +1297,32 @@ export const resourceMethods = {
|
||||
for (const component of ledger.values()) {
|
||||
if (component.civ < 0) continue;
|
||||
component.available = component.supply + component.imports;
|
||||
const industrial = component.converterEnergy + component.foodEnergy;
|
||||
component.industrialScale = industrial > 0
|
||||
? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial))
|
||||
: 1;
|
||||
const cityServed = Math.min(
|
||||
component.cityEnergy,
|
||||
Math.max(0, component.available - industrial * component.industrialScale)
|
||||
);
|
||||
// Cities are served first, then the food the region grows, then the
|
||||
// converters: a famine is worse than an idle factory, so the power that
|
||||
// feeds people is never throttled away by industry.
|
||||
const cityServed = Math.min(component.cityEnergy, component.available);
|
||||
component.cityShortage = component.cityEnergy > 0
|
||||
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
|
||||
: 0;
|
||||
const afterCity = Math.max(0, component.available - cityServed);
|
||||
const foodServed = Math.min(component.foodEnergy, afterCity);
|
||||
component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1;
|
||||
const afterFood = afterCity - foodServed;
|
||||
component.industrialScale = component.converterEnergy > 0
|
||||
? Math.max(0, Math.min(1, afterFood / component.converterEnergy))
|
||||
: 1;
|
||||
market.get("energy").supply += component.supply;
|
||||
market.get("energy").demand += component.demand;
|
||||
}
|
||||
// A grid that cannot power its industry names the works left idle.
|
||||
this._noteGridPowerShortages(ledger, components);
|
||||
|
||||
// Food lands in the region's store, scaled by the power the grid could give.
|
||||
// Food lands in the region's store, scaled by the power the grid could give
|
||||
// it. Food is served before the converters, so the harvest survives a
|
||||
// power squeeze that idles the factories.
|
||||
for (const entry of pendingFood) {
|
||||
const component = ledger.get(entry.component);
|
||||
const scale = component ? component.industrialScale : 1;
|
||||
const scale = component ? component.foodScale : 1;
|
||||
const produced = entry.amount * scale;
|
||||
if (produced <= 0) continue;
|
||||
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
|
||||
@@ -1268,7 +1336,8 @@ export const resourceMethods = {
|
||||
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
|
||||
const component = ledger.get(components.get(k));
|
||||
const scale = component ? component.industrialScale : 1;
|
||||
const produced = proto.outputPerDay * scale;
|
||||
const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1;
|
||||
const produced = this.resourceBuildingOutputAt(k, proto) * throttle * scale;
|
||||
if (produced <= 0) continue;
|
||||
this._ensureResourceStock(k)[proto.resource] += produced;
|
||||
market.get(proto.resource).supply += produced;
|
||||
@@ -1276,7 +1345,11 @@ export const resourceMethods = {
|
||||
|
||||
// The people eat, wear and spend. Shortfalls are matched against reachable
|
||||
// stores -- home regions first, then foreign ones.
|
||||
this._consumeCityResources(components, market);
|
||||
this._consumeCityResources(components, market, nodes);
|
||||
|
||||
// Power is a paid private flow: the grid's consumers settle with its plants,
|
||||
// after the day's sales so an agent can pay from what it just earned.
|
||||
this._settleGridPower(ledger);
|
||||
|
||||
// A region that could not feed its people is in famine: it tells the world,
|
||||
// and its people start dying.
|
||||
@@ -1341,56 +1414,98 @@ export const resourceMethods = {
|
||||
// supply, filled in as the world is walked.
|
||||
_buildEnergyLedger(components) {
|
||||
const ledger = new Map();
|
||||
// Each converter's production scale for the day, so its power draw matches
|
||||
// what it actually intends to make. Read again by the production loop.
|
||||
this._converterScales = new Map();
|
||||
for (const [k, id] of components) {
|
||||
if (ledger.has(id)) continue;
|
||||
ledger.set(id, {
|
||||
civ: this.territory.get(k),
|
||||
supply: 0,
|
||||
// The draw that makes up the physical balance: cities and their
|
||||
// buildings, the converters and the land's food, plus the works' wear
|
||||
// and the plants' own fuel as internal losses.
|
||||
cityEnergy: 0,
|
||||
converterEnergy: 0,
|
||||
foodEnergy: 0,
|
||||
fuelEnergy: 0,
|
||||
// The part of `cityEnergy` the regions actually pay for, so the works'
|
||||
// and plants' internal losses are not billed to them.
|
||||
billableCityEnergy: 0,
|
||||
demand: 0,
|
||||
available: 0,
|
||||
industrialScale: 1,
|
||||
foodScale: 1,
|
||||
cityShortage: 0,
|
||||
// Who makes the power and who draws it, so the grid's bill can be
|
||||
// settled between them: the plants sell, the converters pay from their
|
||||
// own cash and the regions pay for their cities and their food.
|
||||
producers: [],
|
||||
cities: [],
|
||||
converters: [],
|
||||
foodConsumers: [],
|
||||
imports: 0,
|
||||
exports: 0,
|
||||
importCost: 0,
|
||||
});
|
||||
}
|
||||
for (const city of this.cities) {
|
||||
const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
|
||||
if (!component) continue;
|
||||
component.cityEnergy += cityEnergyPerDay(this.getCityEconomy(city).gdp);
|
||||
let amount = cityEnergyPerDay(this.getCityEconomy(city).gdp);
|
||||
// The city's buildings run on the grid too, so their operating power is
|
||||
// charged to the same component.
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
if (level <= 0) continue;
|
||||
const proto = this.protoBuildings[Number(index)];
|
||||
if (proto) component.cityEnergy += this.buildingUpkeepResources(proto, level).energy;
|
||||
if (proto) amount += this.buildingUpkeepResources(proto, level).energy;
|
||||
}
|
||||
component.cityEnergy += amount;
|
||||
component.billableCityEnergy += amount;
|
||||
component.cities.push({ city, amount });
|
||||
}
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
const proto = tileImprovementById(id);
|
||||
if (!proto) continue;
|
||||
const component = ledger.get(components.get(k));
|
||||
if (!component) continue;
|
||||
if (isResourceTileBuilding(proto) && proto.resource === "energy") {
|
||||
component.supply += proto.outputPerDay;
|
||||
} else if (isResourceTileBuilding(proto)) {
|
||||
component.converterEnergy += proto.energyPerDay || 0;
|
||||
if (isResourceTileBuilding(proto)) {
|
||||
if (proto.resource === "energy") {
|
||||
const output = this.resourceBuildingOutputAt(k, proto);
|
||||
component.supply += output;
|
||||
component.producers.push({ k, output });
|
||||
} else {
|
||||
const agent = this.buildingAgents ? this.buildingAgents.get(k) : null;
|
||||
const scale = this._converterProductionScale(k, proto, agent);
|
||||
this._converterScales.set(k, scale);
|
||||
const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale;
|
||||
component.converterEnergy += amount;
|
||||
component.converters.push({ k, amount });
|
||||
}
|
||||
if (proto.fuelEnergyPerDay) {
|
||||
const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay");
|
||||
component.fuelEnergy += fuel;
|
||||
component.cityEnergy += fuel;
|
||||
}
|
||||
}
|
||||
// Every work's operating power is a draw on the grid; it rides with the
|
||||
// cities and is not billed back to a region.
|
||||
component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy;
|
||||
if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
|
||||
}
|
||||
return ledger;
|
||||
},
|
||||
|
||||
// Moves power between grids. A grid in deficit buys from the reachable grids
|
||||
// that hold a surplus, cheapest route first; power is a flow with no store, so
|
||||
// it flows the same day. Each importer's treasury pays the exporter, whose
|
||||
// regions receive the money.
|
||||
// it flows the same day. The seller's plants are paid for the power, and the
|
||||
// buyer's consumers carry the cost. No government is involved.
|
||||
_tradeGridPower(ledger, components) {
|
||||
const price = this.getResourcePrice("energy");
|
||||
const surplus = new Map();
|
||||
for (const [id, component] of ledger) {
|
||||
component.imports = 0;
|
||||
component.exports = 0;
|
||||
component.importCost = 0;
|
||||
surplus.set(id, Math.max(0, component.supply - component.demand));
|
||||
}
|
||||
for (const [id, component] of ledger) {
|
||||
@@ -1404,31 +1519,70 @@ export const resourceMethods = {
|
||||
.filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0)
|
||||
.sort((a, b) => a[1] - b[1]);
|
||||
let needed = deficit;
|
||||
let spent = 0;
|
||||
for (const [sid, routeEnergy] of suppliers) {
|
||||
if (needed <= 0) break;
|
||||
const available = surplus.get(sid) || 0;
|
||||
const take = Math.min(needed, available);
|
||||
if (!(take > 0)) continue;
|
||||
// The power costs its price plus the energy burned carrying it.
|
||||
const cost = take * (price + routeEnergy * price);
|
||||
// The power costs its price plus the energy burned carrying it. A unit
|
||||
// of energy weighs `tonnesPerUnit`, so the freight is charged on the
|
||||
// real tonnage moved, not as if every kWh were a tonne.
|
||||
const tonnes = take * (resourceById("energy")?.tonnesPerUnit || 1);
|
||||
const cost = take * price + tonnes * routeEnergy * price;
|
||||
const seller = ledger.get(sid);
|
||||
this.budgets.set(component.civ, this.getBudget(component.civ) - cost);
|
||||
this._recordBudgetCash(component.civ, "energy", -cost);
|
||||
this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost);
|
||||
this._payGridProducers(seller, cost, take);
|
||||
if (seller.civ !== component.civ) {
|
||||
this._creditRegions(seller.civ, cost);
|
||||
this._recordExternalBuy(component.civ, "energy", take, cost);
|
||||
this._recordExternalSell(seller.civ, "energy", take, cost);
|
||||
} else {
|
||||
this._creditRegions(seller.civ, cost);
|
||||
}
|
||||
surplus.set(sid, available - take);
|
||||
component.imports += take;
|
||||
component.importCost += cost;
|
||||
seller.exports += take;
|
||||
needed -= take;
|
||||
spent += cost;
|
||||
}
|
||||
if (spent > 0) this._recordResourceSpend(component.civ, "energy", spent, "energy");
|
||||
}
|
||||
},
|
||||
|
||||
// Settles one grid's power bill: the regions pay for their cities' and their
|
||||
// food's draw, the converters pay for their own from their cash, and the
|
||||
// plants are paid for what was actually delivered. The works' wear and the
|
||||
// plants' fuel are the grid's internal losses, folded into the price.
|
||||
_settleGridPower(ledger) {
|
||||
const price = this.getResourcePrice("energy");
|
||||
for (const component of ledger.values()) {
|
||||
if (component.civ < 0) continue;
|
||||
const cityServed = component.cityEnergy * (1 - (component.cityShortage || 0));
|
||||
const billableCityServed = Math.min(cityServed, component.billableCityEnergy);
|
||||
const foodServed = component.foodEnergy * (component.foodScale ?? 1);
|
||||
const converterServed = component.converterEnergy * (component.industrialScale ?? 1);
|
||||
const billableServed = billableCityServed + foodServed + converterServed;
|
||||
if (!(billableServed > 0)) continue;
|
||||
const totalConsumed = cityServed + foodServed + converterServed;
|
||||
const ownUsed = Math.min(component.supply, totalConsumed);
|
||||
const bill = ownUsed * price + (component.importCost || 0);
|
||||
const unitPrice = bill / billableServed;
|
||||
const code = this.currencyOf(component.civ).code;
|
||||
const cityFactor = component.billableCityEnergy > 0
|
||||
? billableCityServed / component.billableCityEnergy
|
||||
: 0;
|
||||
const foodFactor = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 0;
|
||||
const converterFactor = component.converterEnergy > 0
|
||||
? converterServed / component.converterEnergy
|
||||
: 0;
|
||||
for (const entry of component.cities || []) {
|
||||
const cost = entry.amount * cityFactor * unitPrice;
|
||||
if (cost > 0) this._chargeRegionCash(entry.city, code, cost);
|
||||
}
|
||||
for (const entry of component.foodConsumers || []) {
|
||||
const cost = entry.amount * foodFactor * unitPrice;
|
||||
if (cost > 0) this._chargeRegionCash(entry.city, code, cost);
|
||||
}
|
||||
for (const entry of component.converters || []) {
|
||||
const cost = entry.amount * converterFactor * unitPrice;
|
||||
if (cost > 0) this._chargeBuildingCash(entry.k, code, cost);
|
||||
}
|
||||
this._payGridProducers(component, ownUsed * price, ownUsed);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1486,8 +1640,8 @@ export const resourceMethods = {
|
||||
return reach;
|
||||
},
|
||||
|
||||
_consumeCityResources(components, market) {
|
||||
const nodes = this._resourceNodes();
|
||||
_consumeCityResources(components, market, nodes = null) {
|
||||
nodes = nodes || this._resourceNodes();
|
||||
// How much of each supplier's surplus earlier buyers have already claimed
|
||||
// today, so a contested surplus goes to whoever bids highest.
|
||||
this._nodeClaims = new Map();
|
||||
@@ -1679,6 +1833,10 @@ export const resourceMethods = {
|
||||
supplier.stock[id] -= bought;
|
||||
cityStore[id] += bought;
|
||||
remaining -= bought;
|
||||
// A production building's sale feeds the demand trend it grows on.
|
||||
if (supplier.kind === "building") {
|
||||
this._recordBuildingSale(key(supplier.coords.x, supplier.coords.y), bought);
|
||||
}
|
||||
this.resourceLinks.push({
|
||||
from: [supplier.coords.x, supplier.coords.y],
|
||||
to: [city.coords.x, city.coords.y],
|
||||
@@ -1689,9 +1847,15 @@ export const resourceMethods = {
|
||||
return amount - remaining;
|
||||
},
|
||||
|
||||
// Pays the region that owns a surplus, into its private-sector cash.
|
||||
// Pays the producer that owns a surplus. A resource building is an
|
||||
// independent private-sector agent, so its goods are paid into its own cash;
|
||||
// a city's surplus is paid to the region that stores it.
|
||||
_paySupplier(node, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
if (node.kind === "building") {
|
||||
this._creditBuildingCash(key(node.coords.x, node.coords.y), code, amount);
|
||||
return;
|
||||
}
|
||||
const city = node.city || this._regionCityNear(node.civ, node.coords);
|
||||
if (city) this._creditRegionCash(city, code, amount);
|
||||
},
|
||||
@@ -1725,17 +1889,6 @@ export const resourceMethods = {
|
||||
return best;
|
||||
},
|
||||
|
||||
// The capital, or the nation's first city when it has no capital.
|
||||
_capitalCity(civ) {
|
||||
let first = null;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
if (city.isCapital) return city;
|
||||
if (!first) first = city;
|
||||
}
|
||||
return first;
|
||||
},
|
||||
|
||||
// The market's delivered price per unit: the good plus the energy a nominal
|
||||
// two-tile road delivery burns. Used to value materials, not to source them.
|
||||
_marketUnitCost(id) {
|
||||
|
||||
@@ -157,6 +157,8 @@ export const serializationMethods = {
|
||||
taxes: null,
|
||||
taxTake: { sales: 0, export: 0, import: 0 },
|
||||
commodityInflation: 0,
|
||||
privateBuildingCash: 0,
|
||||
productionBuildings: [],
|
||||
};
|
||||
}
|
||||
const headline = this._civHeadline(viewerCiv);
|
||||
@@ -197,6 +199,10 @@ export const serializationMethods = {
|
||||
exchangeRates: this._serializeExchangeRates(viewerCiv),
|
||||
centralBank: this._serializeCentralBank(viewerCiv),
|
||||
privateCash: this.getPrivateSectorCash(viewerCiv),
|
||||
// Production buildings are private-sector agents too, but a separate
|
||||
// class from the regions: they keep their own cash, level and upgrade.
|
||||
privateBuildingCash: this.getPrivateBuildingCash(viewerCiv),
|
||||
productionBuildings: this.getProductionAgents(viewerCiv),
|
||||
// Where every unit of the viewer's currency is stored: its treasury, its
|
||||
// regions, and foreign regions and central banks that hold it.
|
||||
currencyHoldings: this.getCurrencyHoldings(viewerCiv),
|
||||
@@ -334,6 +340,7 @@ export const serializationMethods = {
|
||||
id,
|
||||
this.tileImprovementOwner.get(k) ?? -1,
|
||||
this.tileImprovementHp.get(k) || 0,
|
||||
this.resourceBuildingLevel(k),
|
||||
]);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -78,50 +78,6 @@ export const statusMethods = {
|
||||
return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea"));
|
||||
},
|
||||
|
||||
// Land connected components for one civ, with enemy-occupied and enemy-held
|
||||
// city tiles acting as walls. Each component remembers how many of the civ's
|
||||
// cities it contains. Used by the unit supply rule to find a clear route to
|
||||
// an allied city.
|
||||
_landComponents(civ) {
|
||||
const byTile = new Map();
|
||||
const cityCountByComp = new Map();
|
||||
const totalCities = this.cities.filter((city) => city.civ === civ).length;
|
||||
let component = 0;
|
||||
for (const start of this.landCells) {
|
||||
const startKey = key(start.x, start.y);
|
||||
if (byTile.has(startKey) || !this._landPassable(start, civ)) continue;
|
||||
const id = component++;
|
||||
byTile.set(startKey, id);
|
||||
let cities = 0;
|
||||
let frontier = [start];
|
||||
while (frontier.length > 0) {
|
||||
const next = [];
|
||||
for (const coords of frontier) {
|
||||
const city = this.cityAt(coords);
|
||||
if (city && city.civ === civ) cities += 1;
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue;
|
||||
byTile.set(nk, id);
|
||||
next.push(neighbour);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
cityCountByComp.set(id, cities);
|
||||
}
|
||||
return { byTile, cityCountByComp, totalCities };
|
||||
},
|
||||
|
||||
_landPassable(coords, civ) {
|
||||
const tile = this.tiles[key(coords.x, coords.y)];
|
||||
if (!tile || tile.terrainClass !== "Land") return false;
|
||||
if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false;
|
||||
const city = this.cityAt(coords);
|
||||
if (city && city.civ !== civ) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
_statusHpLossPerDay(def, inCity) {
|
||||
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
|
||||
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
|
||||
import { tileImprovementById } from "../data/improvements.js";
|
||||
import { TRENCH } from "../data/combat.js";
|
||||
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
||||
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
|
||||
@@ -54,7 +54,7 @@ export const warfareMethods = {
|
||||
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;
|
||||
if (proto.coastal && !this.isCoastalCoords(coords)) return false;
|
||||
// Resource producers feed the map, not a city: they stand on open land.
|
||||
if (proto.resource && this.cityAt(coords)) return false;
|
||||
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
||||
@@ -89,6 +89,7 @@ export const warfareMethods = {
|
||||
if (this.tileImprovements.delete(k)) {
|
||||
this.tileImprovementOwner.delete(k);
|
||||
this.tileImprovementHp.delete(k);
|
||||
this._removeBuildingAgent(k);
|
||||
removed = true;
|
||||
}
|
||||
if (!removed) return false;
|
||||
@@ -128,26 +129,13 @@ export const warfareMethods = {
|
||||
this.tileImprovements.set(k, work.id);
|
||||
this.tileImprovementOwner.set(k, civ);
|
||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
|
||||
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
|
||||
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) {
|
||||
@@ -193,6 +181,7 @@ export const warfareMethods = {
|
||||
this.tileImprovements.delete(k);
|
||||
this.tileImprovementOwner.delete(k);
|
||||
this.tileImprovementHp.delete(k);
|
||||
this._removeBuildingAgent(k);
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
@@ -285,7 +274,9 @@ export const warfareMethods = {
|
||||
// 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) {
|
||||
// Iterate a copy: boarding destroys the infantry, and splicing the live
|
||||
// array would skip whatever unit follows them.
|
||||
for (const unit of this.units.slice()) {
|
||||
if (!unit.embarking) continue;
|
||||
unit.embarking.hoursLeft -= 1;
|
||||
if (unit.embarking.hoursLeft > 0) continue;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.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";
|
||||
|
||||
+35
-9
@@ -80,6 +80,41 @@ export function foodMonthlyTarget(population) {
|
||||
RESOURCE_RULES.foodMonthlyBuffer;
|
||||
}
|
||||
|
||||
// The energy a region spends to force `extraFood` tonnes a day from the land
|
||||
// beyond its harvest. Quadratic in the extra food, so the marginal tonne gets
|
||||
// dearer the harder the land is pushed.
|
||||
export function foodSynthesisEnergy(extraFood) {
|
||||
const x = Math.max(0, extraFood);
|
||||
return RESOURCE_RULES.foodSynthesisQuadratic * x * x;
|
||||
}
|
||||
|
||||
// The marginal energy for one more tonne of synthesised food, the slope of
|
||||
// `foodSynthesisEnergy`.
|
||||
export function foodSynthesisMarginalEnergy(extraFood) {
|
||||
return 2 * RESOURCE_RULES.foodSynthesisQuadratic * Math.max(0, extraFood);
|
||||
}
|
||||
|
||||
// How much extra food a region grows for a `shortfall` it must cover. Growing
|
||||
// is always cheapest for the first tonnes (its marginal energy starts near
|
||||
// zero) and buying is a flat price, so the region grows while the marginal
|
||||
// energy is cheaper than the market, up to the break-even. Then it completes
|
||||
// with the dearer option: what the reachable market cannot actually supply --
|
||||
// `buyable` tonnes -- it must grow itself, even past the break-even. With an
|
||||
// unreachable or bottomless market the default `buyable = Infinity` gives the
|
||||
// plain break-even result.
|
||||
export function synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable = Infinity) {
|
||||
const gap = Math.max(0, shortfall);
|
||||
if (!(gap > 0)) return 0;
|
||||
const forced = Math.max(0, gap - Math.max(0, buyable));
|
||||
const quadratic = RESOURCE_RULES.foodSynthesisQuadratic;
|
||||
if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) {
|
||||
return Math.min(gap, forced);
|
||||
}
|
||||
const breakEven = foodPrice / (2 * quadratic * energyPrice);
|
||||
if (!(breakEven > 0)) return Math.min(gap, forced);
|
||||
return Math.min(gap, Math.max(breakEven, forced));
|
||||
}
|
||||
|
||||
// A city's baseline electric appetite, kWh a day, from its yearly GDP.
|
||||
export function cityEnergyPerDay(gdp) {
|
||||
return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
|
||||
@@ -200,15 +235,6 @@ export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementC
|
||||
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
|
||||
}
|
||||
|
||||
// The transport mode a tile offers, most efficient first: rail beats road, and
|
||||
// an unimproved tile falls back to an off-road truck. Sea legs ship.
|
||||
export function transportModeForTile(tile, road, railway, sea) {
|
||||
if (sea || (tile && tile.terrainClass === "Sea")) return "ship";
|
||||
if (railway) return "train";
|
||||
if (road) return "road";
|
||||
return "truck";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- market --
|
||||
|
||||
// Moves a price toward what the day's world-wide supply and demand imply. A
|
||||
|
||||
@@ -15,12 +15,6 @@ 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.
|
||||
export function buildingBuildCost(proto, level) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { parseKey, key } from "../shared/hex.js";
|
||||
import { AIR_STRIKE, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { RESOURCE_RULES } from "../shared/data.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { RESOURCE_RULES } from "../shared/data.js";
|
||||
import {
|
||||
foodNeedPerDay,
|
||||
tileFoodOutput,
|
||||
foodSynthesisEnergy,
|
||||
foodSynthesisMarginalEnergy,
|
||||
synthesisedFoodFor,
|
||||
} from "../shared/resources.js";
|
||||
|
||||
const Q = RESOURCE_RULES.foodSynthesisQuadratic;
|
||||
|
||||
// The land's daily harvest in one region, exactly as the daily tick sums it.
|
||||
function regionFood(state, city) {
|
||||
let total = 0;
|
||||
for (const coords of state.regionTiles(city)) {
|
||||
const k = key(coords.x, coords.y);
|
||||
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
|
||||
total += tileFoodOutput(state.tiles[k]) * state._regionFoodMultiplier(city);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export class FoodSynthesisMathTest extends TestCase {
|
||||
test_energy_for_extra_food_is_quadratic() {
|
||||
this.assertApprox(foodSynthesisEnergy(0), 0);
|
||||
this.assertApprox(foodSynthesisEnergy(10), Q * 100);
|
||||
this.assertApprox(foodSynthesisEnergy(20), 4 * foodSynthesisEnergy(10));
|
||||
}
|
||||
|
||||
test_the_marginal_tonne_costs_more_than_the_last() {
|
||||
this.assertApprox(foodSynthesisMarginalEnergy(0), 0);
|
||||
this.assertApprox(foodSynthesisMarginalEnergy(10), 2 * Q * 10);
|
||||
this.assertGreater(
|
||||
foodSynthesisMarginalEnergy(20), foodSynthesisMarginalEnergy(10),
|
||||
"diminishing returns: the next tonne costs more"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_region_grows_until_energy_meets_the_market_price() {
|
||||
const foodPrice = 5100;
|
||||
const energyPrice = 0.051;
|
||||
const breakEven = foodPrice / (2 * Q * energyPrice);
|
||||
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 1e6), breakEven);
|
||||
// A shortfall smaller than the break-even caps the extra food.
|
||||
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 10), 10);
|
||||
// Cheap energy grows past a larger shortfall; dear energy grows almost none.
|
||||
this.assertGreater(synthesisedFoodFor(foodPrice, 0.0001, 1e6), breakEven);
|
||||
this.assertApprox(
|
||||
synthesisedFoodFor(foodPrice, 1000, 1e6), foodPrice / (2 * Q * 1000)
|
||||
);
|
||||
this.assertApprox(synthesisedFoodFor(foodPrice, 1e9, 1e6), foodPrice / (2 * Q * 1e9));
|
||||
}
|
||||
|
||||
test_no_synthesis_without_a_price_or_a_shortfall() {
|
||||
this.assertApprox(synthesisedFoodFor(5100, 0, 1000), 0);
|
||||
this.assertApprox(synthesisedFoodFor(0, 0.05, 1000), 0);
|
||||
this.assertApprox(synthesisedFoodFor(5100, 0.05, 0), 0);
|
||||
this.assertApprox(synthesisedFoodFor(5100, 0.05, -10), 0);
|
||||
}
|
||||
|
||||
test_the_region_grows_whatever_the_market_cannot_supply() {
|
||||
const foodPrice = 5100;
|
||||
const energyPrice = 0.051;
|
||||
const shortfall = 10_000;
|
||||
const breakEven = foodPrice / (2 * Q * energyPrice);
|
||||
// No reachable supplier: grow the whole shortfall.
|
||||
this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, shortfall, 0), shortfall);
|
||||
// A market that covers only part: grow the rest beyond the break-even.
|
||||
this.assertApprox(
|
||||
synthesisedFoodFor(foodPrice, energyPrice, shortfall, 4000),
|
||||
shortfall - 4000
|
||||
);
|
||||
// A market that covers the affordable portion: grow only the break-even.
|
||||
this.assertApprox(
|
||||
synthesisedFoodFor(foodPrice, energyPrice, shortfall, 9000),
|
||||
breakEven
|
||||
);
|
||||
this.assertGreater(breakEven, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export class FoodSynthesisModelTest extends TestCase {
|
||||
test_a_region_short_of_food_forces_extra_from_energy() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) -
|
||||
regionFood(state, city);
|
||||
this.assertGreater(shortfall, 0, "the fixture region opens short of food");
|
||||
// Cheap energy against a dear market: force as much as the shortfall allows.
|
||||
state.setResourcePrice("energy", 0.00001);
|
||||
state.setResourcePrice("food", 1e9);
|
||||
state._tickResources();
|
||||
const extra = state._foodSynthesis.get(city.id) || 0;
|
||||
this.assertApprox(extra, shortfall, 1, "the whole shortfall is grown");
|
||||
}
|
||||
|
||||
test_a_region_buys_instead_when_energy_is_dearer_than_food() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
state.setResourcePrice("energy", 1e9);
|
||||
state.setResourcePrice("food", 1);
|
||||
state._tickResources();
|
||||
this.assertApprox(state._foodSynthesis.get(city.id) || 0, 0);
|
||||
}
|
||||
|
||||
test_the_extra_food_lands_in_the_region_s_store() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
state.setResourcePrice("energy", 0.00001);
|
||||
state.setResourcePrice("food", 1e9);
|
||||
state._tickResources();
|
||||
const produced = state.getCivResourceSummary(0).production.food;
|
||||
this.assertGreater(
|
||||
produced, regionFood(state, city),
|
||||
"the nation's food production includes the forced extra"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { GameScreen } from "../client/js/game_screen.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||||
import { hoursInNextMonth } from "../shared/game_clock.js";
|
||||
import { compact } from "../shared/text_format.js";
|
||||
import { tileImprovementResourceCost } from "../shared/resources.js";
|
||||
@@ -417,23 +416,47 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_unaffordable_air_strike_is_refused_with_a_message() {
|
||||
async test_air_strike_without_munitions_is_refused_with_a_message() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||||
const goal = adjacentLand(state, home);
|
||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
const snapshot = state.snapshot(0);
|
||||
// The server charges an air strike in munitions, not money: without the
|
||||
// steel and high-tech in store the browser must refuse it.
|
||||
snapshot.viewerStats.resources.stock.steel = 0;
|
||||
snapshot.viewerStats.resources.stock.hightech = 0;
|
||||
screen.onState(snapshot);
|
||||
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
|
||||
this.assertEqual(network.orders.length, 0, "no order is sent without the budget");
|
||||
this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget");
|
||||
this.assertEqual(network.orders.length, 0, "no order is sent without the munitions");
|
||||
this.assertEqual(env.$("#confirm-title").text(), "Insufficient munitions");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_air_strike_with_munitions_is_allowed() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertNull(
|
||||
screen._airStrikeBlocked([screen._unit(jet.id)]),
|
||||
"full stores arm the aircraft"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { EFFECT_RESEARCH } from "../shared/data.js";
|
||||
import { tileFoodOutput } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import {
|
||||
RESOURCE_BUILDING_UPGRADE,
|
||||
resourceBuildingOutputAt,
|
||||
resourceBuildingInputAt,
|
||||
resourceBuildingUpgradeMoneyCost,
|
||||
tileImprovementById,
|
||||
} from "../shared/data.js";
|
||||
|
||||
// A production building of `id` on the first land tile of `civ`'s that is not a
|
||||
// city, wired into the agent maps and with an empty stock. The tile may already
|
||||
// carry a work; it is replaced.
|
||||
function placeProducer(state, id, civ = 0) {
|
||||
let coords = null;
|
||||
for (const candidate of state._territoryByCiv.get(civ) || []) {
|
||||
if (state.cityAt(candidate)) continue;
|
||||
if (!state._isLand(candidate)) continue;
|
||||
if (state.tileImprovements.has(key(candidate.x, candidate.y))) continue;
|
||||
coords = candidate;
|
||||
break;
|
||||
}
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, id);
|
||||
state.tileImprovementOwner.set(k, civ);
|
||||
state.tileImprovementHp.set(k, 1000);
|
||||
state._ensureResourceStock(k);
|
||||
const agent = state._ensureBuildingAgent(k);
|
||||
return { coords, k, agent };
|
||||
}
|
||||
|
||||
// Gives the nation's cities enough stores to cover an upgrade's steel bill.
|
||||
function fillMaterialStores(state, civ, amount = 1e7) {
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
state.getCityResourceStock(city).steel = amount;
|
||||
state.getCityResourceStock(city).hightech = amount;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductionBuildingUpgradeMathTest extends TestCase {
|
||||
test_output_compounds_exponentially_with_the_level() {
|
||||
const proto = { outputPerDay: 1000 };
|
||||
const alpha = RESOURCE_BUILDING_UPGRADE.alpha;
|
||||
this.assertApprox(resourceBuildingOutputAt(proto, 0), 1000);
|
||||
this.assertApprox(resourceBuildingOutputAt(proto, 1), 1000 * alpha);
|
||||
this.assertApprox(resourceBuildingOutputAt(proto, 2), 1000 * alpha * alpha);
|
||||
}
|
||||
|
||||
test_input_grows_linearly_with_the_level() {
|
||||
const proto = { energyPerDay: 500 };
|
||||
this.assertApprox(resourceBuildingInputAt(proto, 0, "energyPerDay"), 500);
|
||||
this.assertApprox(resourceBuildingInputAt(proto, 2, "energyPerDay"), 1500);
|
||||
this.assertApprox(
|
||||
resourceBuildingInputAt(proto, 1, "energyPerDay"),
|
||||
500 * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel)
|
||||
);
|
||||
}
|
||||
|
||||
test_each_upgrade_cost_more_than_the_last() {
|
||||
const proto = { buildCost: 800_000_000 };
|
||||
const first = resourceBuildingUpgradeMoneyCost(proto, 0);
|
||||
const second = resourceBuildingUpgradeMoneyCost(proto, 1);
|
||||
this.assertApprox(first, 800_000_000);
|
||||
this.assertGreater(second, first);
|
||||
this.assertApprox(second, 800_000_000 * RESOURCE_BUILDING_UPGRADE.costAlpha);
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductionBuildingAgentTest extends TestCase {
|
||||
test_a_production_building_keeps_what_it_sells() {
|
||||
const state = smallState();
|
||||
const { coords, k, agent } = placeProducer(state, "steel_mill");
|
||||
const code = state.currencyOf(0).code;
|
||||
const city = cityOf(state, 0);
|
||||
const regionBefore = state.getRegionCashValue(city);
|
||||
state._paySupplier({ kind: "building", coords, civ: 0 }, code, 12_345);
|
||||
this.assertApprox(agent.cash, 12_345, 1e-6, "the sale reached the building");
|
||||
this.assertApprox(state.getRegionCashValue(city), regionBefore, 1e-6, "not the region");
|
||||
this.assertEqual(state.getPrivateBuildingCash(0), agent.cash);
|
||||
}
|
||||
|
||||
test_plants_trade_their_power_like_any_producer() {
|
||||
const state = smallState();
|
||||
const plant = tileImprovementById("coal_power_plant");
|
||||
const mill = tileImprovementById("steel_mill");
|
||||
this.assertTrue(state._isProductionAgent(plant), "a plant sells power");
|
||||
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
|
||||
}
|
||||
|
||||
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
|
||||
const state = smallState();
|
||||
const { agent } = placeProducer(state, "steel_mill");
|
||||
agent.sales = [10, 10, 10];
|
||||
this.assertFalse(state._buildingDemandGrowing(agent), "flat demand is not growth");
|
||||
agent.sales = [20, 20, 20, 30, 30, 30];
|
||||
this.assertTrue(state._buildingDemandGrowing(agent), "rising demand is growth");
|
||||
agent.sales = [30, 30, 30, 20, 20, 20];
|
||||
this.assertFalse(state._buildingDemandGrowing(agent), "falling demand is not growth");
|
||||
}
|
||||
|
||||
test_a_building_starts_an_upgrade_and_cannot_start_a_second() {
|
||||
const state = smallState();
|
||||
const { coords, k, agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 1e12;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNotNull(agent.upgrading, "the upgrade started");
|
||||
const job = agent.upgrading;
|
||||
this.assertEqual(agent.level, 0, "the level waits for the work");
|
||||
// A second read may not replace the work already under way.
|
||||
agent.sales = [100, 200, 300, 400, 500, 600];
|
||||
state._tickBuildingDecisions();
|
||||
this.assertEqual(agent.upgrading, job, "only one upgrade at a time");
|
||||
}
|
||||
|
||||
test_spending_on_an_upgrade_consumes_cash_and_materials() {
|
||||
const state = smallState();
|
||||
const { agent, k } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 1e12;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
const before = agent.cash;
|
||||
state._tickBuildingDecisions();
|
||||
this.assertLess(agent.cash, before, "the agent paid for its materials");
|
||||
this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over");
|
||||
this.assertNotNull(tileImprovementById(state.tileImprovements.get(k)));
|
||||
}
|
||||
|
||||
test_completing_an_upgrade_raises_the_level_and_its_output() {
|
||||
const state = smallState();
|
||||
const { k, agent } = placeProducer(state, "steel_mill");
|
||||
const proto = tileImprovementById("steel_mill");
|
||||
agent.cash = 1e12;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
const baseOutput = state.resourceBuildingOutputAt(k, proto);
|
||||
// Keep the day-boundary decision from firing while the work is completed.
|
||||
state.totalHours = 1;
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNotNull(agent.upgrading);
|
||||
agent.upgrading.totalHours = 0;
|
||||
state._tickIndustry();
|
||||
this.assertEqual(agent.level, 1, "the level arrived");
|
||||
this.assertNull(agent.upgrading, "the work is done");
|
||||
this.assertGreater(
|
||||
state.resourceBuildingOutputAt(k, proto), baseOutput,
|
||||
"the upgraded building makes more"
|
||||
);
|
||||
}
|
||||
|
||||
test_an_agent_without_enough_cash_waits() {
|
||||
const state = smallState();
|
||||
const { agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 0;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNull(agent.upgrading, "no cash, no upgrade");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_production_agents_apart_from_the_regions() {
|
||||
const state = smallState();
|
||||
const { coords, agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 4_000_000;
|
||||
const stats = state.snapshot(0).viewerStats;
|
||||
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
|
||||
const entry = stats.productionBuildings.find(
|
||||
(item) => item.coords[0] === coords.x && item.coords[1] === coords.y
|
||||
);
|
||||
this.assertNotNull(entry, "the building is listed on its own row");
|
||||
this.assertEqual(entry.level, 0);
|
||||
this.assertApprox(entry.cash, 4_000_000, 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
export class PowerMarketTest extends TestCase {
|
||||
test_a_grid_pays_its_plants_for_the_power_it_delivers() {
|
||||
const state = smallState();
|
||||
const plant = placeProducer(state, "coal_power_plant");
|
||||
const converter = placeProducer(state, "steel_mill");
|
||||
const city = cityOf(state, 0);
|
||||
const plantBefore = plant.agent.cash;
|
||||
const converterBefore = converter.agent.cash;
|
||||
const regionBefore = state.getRegionCashValue(city);
|
||||
const price = state.getResourcePrice("energy");
|
||||
const ledger = new Map([[0, {
|
||||
civ: 0,
|
||||
supply: 1000,
|
||||
cityEnergy: 100,
|
||||
converterEnergy: 200,
|
||||
foodEnergy: 0,
|
||||
billableCityEnergy: 100,
|
||||
cityShortage: 0,
|
||||
industrialScale: 1,
|
||||
producers: [{ k: plant.k, output: 1000 }],
|
||||
cities: [{ city, amount: 100 }],
|
||||
converters: [{ k: converter.k, amount: 200 }],
|
||||
foodConsumers: [],
|
||||
imports: 0,
|
||||
importCost: 0,
|
||||
}]]);
|
||||
state._settleGridPower(ledger);
|
||||
this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid");
|
||||
this.assertApprox(converter.agent.cash - converterBefore, -200 * price, 1e-6, "the converter pays");
|
||||
this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6,
|
||||
"the region pays");
|
||||
this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend");
|
||||
}
|
||||
|
||||
test_imported_power_is_paid_to_the_exporting_grid_s_plants() {
|
||||
const state = smallState();
|
||||
const plant = placeProducer(state, "coal_power_plant");
|
||||
const component = { civ: 0, producers: [{ k: plant.k, output: 100 }] };
|
||||
const before = plant.agent.cash;
|
||||
state._payGridProducers(component, 5_000, 40);
|
||||
this.assertApprox(plant.agent.cash - before, 5_000, 1e-6);
|
||||
this.assertApprox(plant.agent.salesToday, 40, 1e-6);
|
||||
}
|
||||
|
||||
test_an_overstocked_converter_throttles_to_what_it_sells() {
|
||||
const state = smallState();
|
||||
const { k, agent } = placeProducer(state, "steel_mill");
|
||||
const proto = tileImprovementById("steel_mill");
|
||||
this.assertEqual(
|
||||
state._converterProductionScale(k, proto, agent), 1,
|
||||
"a building with no history runs flat out"
|
||||
);
|
||||
agent.sales = [4_000, 4_000, 4_000, 4_000];
|
||||
state._ensureResourceStock(k).steel = 1e9;
|
||||
const scale = state._converterProductionScale(k, proto, agent);
|
||||
this.assertLess(scale, 1, "a full store eases off");
|
||||
this.assertApprox(scale, 4_000 / proto.outputPerDay, 1e-3);
|
||||
}
|
||||
|
||||
test_a_converter_sells_out_and_then_grows() {
|
||||
const state = smallState();
|
||||
const { k, agent } = placeProducer(state, "steel_mill");
|
||||
const proto = tileImprovementById("steel_mill");
|
||||
agent.sales = new Array(6).fill(proto.outputPerDay);
|
||||
this.assertTrue(
|
||||
state._buildingDemandGrowing(agent, proto.outputPerDay),
|
||||
"selling out is a reason to grow"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { MONEY } from "../shared/data/economy.js";
|
||||
|
||||
export class MoneyTest extends TestCase {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { MapView } from "../client/js/map_view.js";
|
||||
import { MapTopology, key, parseKey } from "../shared/hex.js";
|
||||
import { MapTopology, key } from "../shared/hex.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
||||
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
TECHNOLOGIES,
|
||||
TERRAIN_TILES,
|
||||
MAP_CONFIG,
|
||||
ECONOMY,
|
||||
TAXES,
|
||||
BATTLE,
|
||||
effectValue,
|
||||
|
||||
+1
-22
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js";
|
||||
import { parseKey, key } from "../shared/hex.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
|
||||
import { MapView } from "../client/js/map_view.js";
|
||||
|
||||
@@ -232,24 +232,3 @@ function findCoastalClearCity(state, civ) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit,
|
||||
// l2 for the coastal destination city.
|
||||
function findCoastalPair(state) {
|
||||
for (const k in state.tiles) {
|
||||
if (state.tiles[k].terrainClass !== "Sea") continue;
|
||||
const s = parseKey(k);
|
||||
const land = state.topology.neighbours(s.x, s.y).filter(
|
||||
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
||||
);
|
||||
for (let i = 0; i < land.length; i++) {
|
||||
for (let j = i + 1; j < land.length; j++) {
|
||||
if (state.topology.tileDistance(land[i], land[j]) <= 1) continue;
|
||||
const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n));
|
||||
if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
|
||||
return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user