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
|
// every money string reads in the right unit. The symbol is set from the
|
||||||
// snapshot; until then a neutral mark stands in.
|
// snapshot; until then a neutral mark stands in.
|
||||||
|
|
||||||
import { groupDigits } from "../../shared/text_format.js";
|
|
||||||
|
|
||||||
let symbol = "¤";
|
let symbol = "¤";
|
||||||
|
|
||||||
export function setCurrency(currency) {
|
export function setCurrency(currency) {
|
||||||
@@ -13,10 +11,3 @@ export function setCurrency(currency) {
|
|||||||
export function currencySymbol() {
|
export function currencySymbol() {
|
||||||
return symbol;
|
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;
|
return $line;
|
||||||
}
|
}
|
||||||
|
|
||||||
lineCount() {
|
|
||||||
return this.$container ? this.$container.children(".feed-line").length : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_retire($line) {
|
_retire($line) {
|
||||||
$line.addClass("leaving");
|
$line.addClass("leaving");
|
||||||
if (this.fade <= 0) {
|
if (this.fade <= 0) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// on the GameScreen instance (composed in ../game_screen.js) and only read the
|
// on the GameScreen instance (composed in ../game_screen.js) and only read the
|
||||||
// snapshot and forward orders.
|
// 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 { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
|
||||||
import {
|
import {
|
||||||
CIVILISATIONS,
|
CIVILISATIONS,
|
||||||
@@ -28,12 +28,12 @@ import {
|
|||||||
resourceById,
|
resourceById,
|
||||||
RESOURCES,
|
RESOURCES,
|
||||||
RESOURCE_RULES,
|
RESOURCE_RULES,
|
||||||
|
resourceBuildingOutputAt,
|
||||||
tileImprovementById,
|
tileImprovementById,
|
||||||
AIR_STRIKE,
|
|
||||||
} from "../../../shared/data.js";
|
} from "../../../shared/data.js";
|
||||||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||||||
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.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 { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||||
import { isEconomicMode } from "../map_view/economic.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
|
// 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
|
// plane that is already flying a mission or is still re-arming, and a nation
|
||||||
// nation cannot afford the (deliberately steep) price.
|
// whose stores cannot cover the munitions the sortie burns.
|
||||||
_airStrikeBlocked(units) {
|
_airStrikeBlocked(units) {
|
||||||
const snapshot = this.snapshot || {};
|
const snapshot = this.snapshot || {};
|
||||||
const totalHours = snapshot.totalHours || 0;
|
const totalHours = snapshot.totalHours || 0;
|
||||||
const budget = (snapshot.viewerStats || {}).budget || 0;
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
if (unit.strikeTarget) {
|
if (unit.strikeTarget) {
|
||||||
return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." };
|
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;
|
// A sortie is paid for in munitions, not money (GameState.requestAirStrike):
|
||||||
if (cost > budget) {
|
// every aircraft spends steel and high-tech from the nation's stores.
|
||||||
const prices = marketPricesFrom(this.snapshot);
|
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 {
|
return {
|
||||||
title: "Insufficient budget",
|
title: "Insufficient munitions",
|
||||||
message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
|
message: "The nation's stores cannot cover this air strike's steel and high-tech.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -937,7 +940,10 @@ export const panelMethods = {
|
|||||||
const militaryWorks = this.map.tileImprovements.get(k);
|
const militaryWorks = this.map.tileImprovements.get(k);
|
||||||
if (militaryWorks) {
|
if (militaryWorks) {
|
||||||
const proto = tileImprovementById(militaryWorks.id);
|
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(
|
$("#tile-improvements").text(
|
||||||
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
|
`Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
|
||||||
@@ -969,19 +975,33 @@ export const panelMethods = {
|
|||||||
const work = queue.find(
|
const work = queue.find(
|
||||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||||
);
|
);
|
||||||
if (!work) return;
|
if (work) {
|
||||||
const proto = tileImprovementById(work.id);
|
const proto = tileImprovementById(work.id);
|
||||||
const name = proto ? proto.name : work.id;
|
const name = proto ? proto.name : work.id;
|
||||||
const total = Math.max(work.totalHours || 0, 0.0001);
|
const total = Math.max(work.totalHours || 0, 0.0001);
|
||||||
const elapsed = work.elapsedHours || 0;
|
const elapsed = work.elapsedHours || 0;
|
||||||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||||
$el.append(
|
$el.append(
|
||||||
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
||||||
$bar
|
$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
|
// 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 improvement = this.map.tileImprovements.get(k);
|
||||||
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
||||||
if (proto && proto.resource && proto.outputPerDay) {
|
if (proto && proto.resource && proto.outputPerDay) {
|
||||||
amounts[proto.resource] += proto.outputPerDay * multiplier;
|
amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier;
|
||||||
}
|
}
|
||||||
return { amounts, factors, foodMultiplier };
|
return { amounts, factors, foodMultiplier };
|
||||||
},
|
},
|
||||||
@@ -1784,6 +1804,10 @@ export const panelMethods = {
|
|||||||
exchangeRates: stats.exchangeRates || [],
|
exchangeRates: stats.exchangeRates || [],
|
||||||
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
||||||
privateCash: stats.privateCash || 0,
|
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.
|
// Where the viewer's own currency sits: treasury, regions, foreign banks.
|
||||||
currencyHoldings: stats.currencyHoldings || null,
|
currencyHoldings: stats.currencyHoldings || null,
|
||||||
// The viewer's trade-tax rates and the day's take, for the Taxes sub-tab.
|
// 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_MIN = 0.2;
|
||||||
export const ZOOM_MAX = 2.0;
|
export const ZOOM_MAX = 2.0;
|
||||||
|
|
||||||
// The camera looks north from an orthographic elevation of 70° above the
|
// The camera looks north from an orthographic elevation of 45° above the
|
||||||
// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the
|
// ground. That foreshortens the north-south axis by sin(45°) ~= 0.71 while the
|
||||||
// east-west axis is untouched: a gentle 3D tilt that keeps the current
|
// east-west axis is untouched. It is applied to the terrain, roads, borders,
|
||||||
// orientation. It is applied to the terrain, roads, borders, fog and ground
|
// fog and ground overlays; units, cities and labels stay upright on top.
|
||||||
// overlays; units, cities and labels stay upright on top.
|
const CAMERA_ELEVATION_DEG = 45;
|
||||||
export const CAMERA_ELEVATION_DEG = 45;
|
|
||||||
export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180);
|
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)
|
// 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
|
// 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
|
// battle icon between them. The side offset is in pixels.
|
||||||
// between the (half-size) units of one stack.
|
|
||||||
export const BATTLE_SIDE_OFFSET = 15;
|
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
|
// 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
|
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export const entityMethods = {
|
|||||||
this._warfareSignature = signature;
|
this._warfareSignature = signature;
|
||||||
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
|
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
|
||||||
this.tileImprovements = new Map();
|
this.tileImprovements = new Map();
|
||||||
for (const [x, y, id, owner, hp] of improvements) {
|
for (const [x, y, id, owner, hp, level] of improvements) {
|
||||||
this.tileImprovements.set(key(x, y), { id, owner, hp });
|
this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 });
|
||||||
}
|
}
|
||||||
this._renderWarfare();
|
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
|
// Adds a convex quad given its four vertices (with per-vertex heights) as two
|
||||||
// triangles. Wind order does not matter: we never cull faces.
|
// triangles. Wind order does not matter: we never cull faces.
|
||||||
_quad(arr, corners, colour) {
|
_quad(arr, corners, colour) {
|
||||||
|
|||||||
+78
-17
@@ -443,15 +443,18 @@ export class NationModal {
|
|||||||
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
|
return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The aggregate private-sector cash the nation's regions hold, and each
|
// The aggregate private-sector cash the nation's regions and its independent
|
||||||
// region's own reserve.
|
// production buildings hold, each listed on its own row.
|
||||||
_renderPrivateSector(money) {
|
_renderPrivateSector(money) {
|
||||||
if (!this.$economySummary || !this.$economySummary.length) return;
|
if (!this.$economySummary || !this.$economySummary.length) return;
|
||||||
const cash = money.privateCash || 0;
|
const cash = money.privateCash || 0;
|
||||||
|
const buildingCash = money.privateBuildingCash || 0;
|
||||||
if (this.$economySub && this.$economySub.length) {
|
if (this.$economySub && this.$economySub.length) {
|
||||||
this.$economySub.text(
|
this.$economySub.text(
|
||||||
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` +
|
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` +
|
||||||
`${(money.currency && money.currency.name) || ""}`
|
` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` +
|
||||||
|
` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` +
|
||||||
|
` · ${(money.currency && money.currency.name) || ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.$economySummary.empty();
|
this.$economySummary.empty();
|
||||||
@@ -462,36 +465,94 @@ export class NationModal {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
|
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 $table = $("<table class='resources-table economy-table'></table>");
|
||||||
const $head = $("<thead></thead>").append(
|
const $head = $("<thead></thead>").append(
|
||||||
$("<tr></tr>")
|
$("<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"))
|
.append($("<th class='numeric'></th>").text("Private cash"))
|
||||||
);
|
);
|
||||||
const $body = $("<tbody></tbody>");
|
const $body = $("<tbody></tbody>");
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const city of cities) {
|
for (const building of buildings) {
|
||||||
total += city.cashValue || 0;
|
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(
|
$body.append(
|
||||||
$("<tr></tr>")
|
$("<tr></tr>")
|
||||||
.append($("<td></td>").text(city.name))
|
.append($("<td></td>").text(building.name))
|
||||||
|
.append($output)
|
||||||
.append(
|
.append(
|
||||||
$("<td class='numeric'></td>").text(
|
$("<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);
|
$table.append($head, $body);
|
||||||
const $foot = $("<tfoot></tfoot>").append(
|
$table.append(
|
||||||
$("<tr class='budget-total'></tr>")
|
$("<tfoot></tfoot>").append(
|
||||||
.append($("<td></td>").text("Total"))
|
$("<tr class='budget-total'></tr>")
|
||||||
.append($("<td class='numeric'></td>").text(
|
.append($("<td colspan='3'></td>").text("Total"))
|
||||||
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
.append($("<td class='numeric'></td>").text(
|
||||||
))
|
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
||||||
|
))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
$table.append($foot);
|
|
||||||
this.$economySummary.append($table);
|
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 { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
|
||||||
import {
|
import {
|
||||||
cityEnergyPerDay,
|
cityEnergyPerDay,
|
||||||
foodNeedPerDay,
|
|
||||||
steelNeedPerDay,
|
|
||||||
luxuryNeedPerDay,
|
|
||||||
} from "../shared/resources.js";
|
} from "../shared/resources.js";
|
||||||
import { key } from "../shared/hex.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
|
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
|
||||||
// friendly airport and then cannot strike again for `cooldownHours` (two days).
|
// friendly airport and then cannot strike again for `cooldownHours` (two days).
|
||||||
// Each attacking aircraft costs `cost` from its nation's budget, charged up
|
// Each attacking aircraft burns munitions from its nation's stores (see
|
||||||
// front so bombing cannot be spammed for free.
|
// RESOURCE_RULES.combatConsumption.airStrike), charged up front so bombing
|
||||||
|
// cannot be spammed for free.
|
||||||
export const AIR_STRIKE = {
|
export const AIR_STRIKE = {
|
||||||
cost: 10_000_000,
|
|
||||||
cooldownHours: 48,
|
cooldownHours: 48,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ export const PROPAGANDA = {
|
|||||||
|
|
||||||
export const CAMPAIGN_ETHNICITY = "ethnicity";
|
export const CAMPAIGN_ETHNICITY = "ethnicity";
|
||||||
export const CAMPAIGN_GOVERNMENT = "government";
|
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
|
// 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.
|
// 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.
|
// Energy is stored and moved as kWh. These keep the catalogue readable.
|
||||||
export const KWH_PER_MWH = 1_000;
|
export const KWH_PER_MWH = 1_000;
|
||||||
export const KWH_PER_GWH = 1_000_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
|
// One tonne of any material counts as this much energy on the wire, so material
|
||||||
// deliveries and energy deliveries share one transport-cost formula.
|
// 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.
|
// Energy the material producers consume to make one canonical unit.
|
||||||
energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne
|
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
|
energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne
|
||||||
energyPerLuxuryCarat: {
|
energyPerLuxuryCarat: {
|
||||||
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
|
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;
|
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(
|
export const RESOURCE_BUILDING_BY_ID = Object.fromEntries(
|
||||||
RESOURCE_BUILDINGS.map((building) => [building.id, building])
|
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 { monthlyMethods } from "./game_state/monthly.js";
|
||||||
import { warfareMethods } from "./game_state/warfare.js";
|
import { warfareMethods } from "./game_state/warfare.js";
|
||||||
import { resourceMethods } from "./game_state/resources.js";
|
import { resourceMethods } from "./game_state/resources.js";
|
||||||
|
import { industryMethods } from "./game_state/industry.js";
|
||||||
import { budgetMethods } from "./game_state/budget.js";
|
import { budgetMethods } from "./game_state/budget.js";
|
||||||
import { moneyMethods } from "./game_state/money.js";
|
import { moneyMethods } from "./game_state/money.js";
|
||||||
import { taxMethods } from "./game_state/taxes.js";
|
import { taxMethods } from "./game_state/taxes.js";
|
||||||
@@ -118,6 +119,9 @@ export class GameState {
|
|||||||
this.tileImprovementOwner = new Map();
|
this.tileImprovementOwner = new Map();
|
||||||
this.tileImprovementHp = new Map();
|
this.tileImprovementHp = new Map();
|
||||||
this.tileWorks = 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;
|
this._tileImprovementVersion = 0;
|
||||||
// Bumped whenever the transport network changes, so the serialised road and
|
// Bumped whenever the transport network changes, so the serialised road and
|
||||||
// railway lists are rebuilt only when something actually moved.
|
// railway lists are rebuilt only when something actually moved.
|
||||||
@@ -384,6 +388,7 @@ export class GameState {
|
|||||||
}
|
}
|
||||||
this._tickBuildings();
|
this._tickBuildings();
|
||||||
this._tickTileWorks();
|
this._tickTileWorks();
|
||||||
|
this._tickIndustry();
|
||||||
this._tickEmbark();
|
this._tickEmbark();
|
||||||
this._tickCoastalCannons();
|
this._tickCoastalCannons();
|
||||||
// A unit no longer sharing a tile with an enemy is not an invader any more.
|
// A unit no longer sharing a tile with an enemy is not an invader any more.
|
||||||
@@ -413,6 +418,7 @@ Object.assign(
|
|||||||
monthlyMethods,
|
monthlyMethods,
|
||||||
warfareMethods,
|
warfareMethods,
|
||||||
resourceMethods,
|
resourceMethods,
|
||||||
|
industryMethods,
|
||||||
budgetMethods,
|
budgetMethods,
|
||||||
moneyMethods,
|
moneyMethods,
|
||||||
taxMethods
|
taxMethods
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export const airMethods = {
|
|||||||
// Applies one ranged blow to a target unit, with the special fates of exposed
|
// 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
|
// support units, and passes a tenth of the damage on to any structures on the
|
||||||
// tile.
|
// tile.
|
||||||
_applyStrikeDamage(target, damage, attackerProto = null) {
|
_applyStrikeDamage(target, damage) {
|
||||||
const proto = this.unitProto(target);
|
const proto = this.unitProto(target);
|
||||||
const alone = this.unitsAt(target.coords).length <= 1;
|
const alone = this.unitsAt(target.coords).length <= 1;
|
||||||
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
|
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
|
||||||
@@ -420,7 +420,7 @@ export const airMethods = {
|
|||||||
const target = this._strikeTargetAt(goal, unit.civ);
|
const target = this._strikeTargetAt(goal, unit.civ);
|
||||||
if (target) {
|
if (target) {
|
||||||
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
|
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
|
||||||
this._applyStrikeDamage(target, damage, this.unitProto(unit));
|
this._applyStrikeDamage(target, damage);
|
||||||
} else {
|
} else {
|
||||||
const city = this._strikeCityAt(goal, unit.civ);
|
const city = this._strikeCityAt(goal, unit.civ);
|
||||||
if (city) {
|
if (city) {
|
||||||
@@ -471,7 +471,7 @@ export const airMethods = {
|
|||||||
if (damage.size === 0) return;
|
if (damage.size === 0) return;
|
||||||
const destroyed = [];
|
const destroyed = [];
|
||||||
for (const [target, amount] of damage) {
|
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();
|
this._emitChanged();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const economyMethods = {
|
|||||||
const id = this.tileImprovements.get(k);
|
const id = this.tileImprovements.get(k);
|
||||||
const proto = id ? tileImprovementById(id) : null;
|
const proto = id ? tileImprovementById(id) : null;
|
||||||
if (isResourceTileBuilding(proto)) {
|
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;
|
return daily * DAYS_PER_YEAR;
|
||||||
@@ -522,15 +522,6 @@ export const economyMethods = {
|
|||||||
return groups.sort((a, b) => b.amount - a.amount);
|
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) {
|
_unitUpkeepSources(civ) {
|
||||||
const sources = new Map();
|
const sources = new Map();
|
||||||
const battle = this._battleUnitIds();
|
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 --
|
// --------------------------------------------------- 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) {
|
_creditRegionCash(city, code, amount) {
|
||||||
if (!(amount > 0)) return;
|
if (!(amount > 0)) return;
|
||||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
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.
|
// An overdrawn treasury is a claim on the bank, not circulating money.
|
||||||
let amount = Math.max(0, this.getBudget(civ) || 0);
|
let amount = Math.max(0, this.getBudget(civ) || 0);
|
||||||
for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 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()) {
|
for (const reserves of this.centralBankReserves.values()) {
|
||||||
amount += reserves.get(code) || 0;
|
amount += reserves.get(code) || 0;
|
||||||
}
|
}
|
||||||
@@ -405,6 +395,19 @@ export const moneyMethods = {
|
|||||||
banks.push({ civ: c, name: host ? host.name : "", amount });
|
banks.push({ civ: c, name: host ? host.name : "", amount });
|
||||||
bankTotal += 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 budget = this.getBudget(civ) || 0;
|
||||||
const treasury = Math.max(0, budget);
|
const treasury = Math.max(0, budget);
|
||||||
const treasuryDebt = Math.max(0, -budget);
|
const treasuryDebt = Math.max(0, -budget);
|
||||||
|
|||||||
@@ -378,19 +378,11 @@ export const politicsMethods = {
|
|||||||
return this.getCivOpinions(civ).approval;
|
return this.getCivOpinions(civ).approval;
|
||||||
},
|
},
|
||||||
|
|
||||||
getCityPopularity(city) {
|
|
||||||
return this.getCityOpinions(city).popularity;
|
|
||||||
},
|
|
||||||
|
|
||||||
getCivPopularity(civ) {
|
getCivPopularity(civ) {
|
||||||
return this.getCivOpinions(civ).popularity;
|
return this.getCivOpinions(civ).popularity;
|
||||||
},
|
},
|
||||||
|
|
||||||
// What the player sees, in 0..1: the popularity points through the logistic.
|
// What the player sees, in 0..1: the popularity points through the logistic.
|
||||||
getCityPopularityDisplay(city) {
|
|
||||||
return popularityDisplay(this.getCityPopularity(city));
|
|
||||||
},
|
|
||||||
|
|
||||||
getCivPopularityDisplay(civ) {
|
getCivPopularityDisplay(civ) {
|
||||||
return popularityDisplay(this.getCivPopularity(civ));
|
return popularityDisplay(this.getCivPopularity(civ));
|
||||||
},
|
},
|
||||||
|
|||||||
+205
-52
@@ -19,6 +19,7 @@ import {
|
|||||||
FAMINE,
|
FAMINE,
|
||||||
tileImprovementById,
|
tileImprovementById,
|
||||||
isResourceTileBuilding,
|
isResourceTileBuilding,
|
||||||
|
resourceById,
|
||||||
MONEY,
|
MONEY,
|
||||||
} from "../data.js";
|
} from "../data.js";
|
||||||
import {
|
import {
|
||||||
@@ -29,9 +30,10 @@ import {
|
|||||||
luxuryNeedPerDay,
|
luxuryNeedPerDay,
|
||||||
foodMonthlyTarget,
|
foodMonthlyTarget,
|
||||||
producerEnergyPerDay,
|
producerEnergyPerDay,
|
||||||
|
foodSynthesisEnergy,
|
||||||
|
synthesisedFoodFor,
|
||||||
deliveryEnergyForResource,
|
deliveryEnergyForResource,
|
||||||
nextMarketPrice,
|
nextMarketPrice,
|
||||||
upkeepResources,
|
|
||||||
protoUpkeep,
|
protoUpkeep,
|
||||||
constructionResourceCost as sharedConstructionResourceCost,
|
constructionResourceCost as sharedConstructionResourceCost,
|
||||||
} from "../resources.js";
|
} from "../resources.js";
|
||||||
@@ -314,6 +316,7 @@ export const resourceMethods = {
|
|||||||
this.tileImprovements.set(k, proto.id);
|
this.tileImprovements.set(k, proto.id);
|
||||||
this.tileImprovementOwner.set(k, civ);
|
this.tileImprovementOwner.set(k, civ);
|
||||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
|
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.
|
// 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)) {
|
if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
|
||||||
this.roads.add(k);
|
this.roads.add(k);
|
||||||
@@ -375,7 +378,7 @@ export const resourceMethods = {
|
|||||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||||
const proto = tileImprovementById(id);
|
const proto = tileImprovementById(id);
|
||||||
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
|
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;
|
return made;
|
||||||
},
|
},
|
||||||
@@ -589,10 +592,17 @@ export const resourceMethods = {
|
|||||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||||
const proto = tileImprovementById(id);
|
const proto = tileImprovementById(id);
|
||||||
if (!isResourceTileBuilding(proto)) continue;
|
if (!isResourceTileBuilding(proto)) continue;
|
||||||
if (proto.resource === "energy") energySupply += proto.outputPerDay;
|
if (proto.resource === "energy") {
|
||||||
else production[proto.resource] += proto.outputPerDay;
|
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();
|
const foodMultipliers = new Map();
|
||||||
for (const coords of this._territoryByCiv.get(civ) || []) {
|
for (const coords of this._territoryByCiv.get(civ) || []) {
|
||||||
const k = key(coords.x, coords.y);
|
const k = key(coords.x, coords.y);
|
||||||
@@ -608,6 +618,12 @@ export const resourceMethods = {
|
|||||||
}
|
}
|
||||||
production.food += tileFoodOutput(this.tiles[k]) * multiplier;
|
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);
|
const upkeep = this.getCivUpkeepResources(civ);
|
||||||
// What the nation actually consumes in a day: the people's needs plus the
|
// 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
|
// 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.
|
// charged to the tile's grid component.
|
||||||
const pendingFood = [];
|
const pendingFood = [];
|
||||||
const foodMultipliers = new Map();
|
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) {
|
for (const k in this.tiles) {
|
||||||
const coords = parseKey(k);
|
const coords = parseKey(k);
|
||||||
if (!this._isLand(coords)) continue;
|
if (!this._isLand(coords)) continue;
|
||||||
@@ -1212,6 +1231,7 @@ export const resourceMethods = {
|
|||||||
}
|
}
|
||||||
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
|
const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
|
||||||
if (produced <= 0) continue;
|
if (produced <= 0) continue;
|
||||||
|
regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced);
|
||||||
pendingFood.push({
|
pendingFood.push({
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
coords: city.coords,
|
coords: city.coords,
|
||||||
@@ -1219,7 +1239,50 @@ export const resourceMethods = {
|
|||||||
amount: produced,
|
amount: produced,
|
||||||
});
|
});
|
||||||
const component = ledger.get(components.get(k));
|
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
|
// 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()) {
|
for (const component of ledger.values()) {
|
||||||
if (component.civ < 0) continue;
|
if (component.civ < 0) continue;
|
||||||
component.available = component.supply + component.imports;
|
component.available = component.supply + component.imports;
|
||||||
const industrial = component.converterEnergy + component.foodEnergy;
|
// Cities are served first, then the food the region grows, then the
|
||||||
component.industrialScale = industrial > 0
|
// converters: a famine is worse than an idle factory, so the power that
|
||||||
? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial))
|
// feeds people is never throttled away by industry.
|
||||||
: 1;
|
const cityServed = Math.min(component.cityEnergy, component.available);
|
||||||
const cityServed = Math.min(
|
|
||||||
component.cityEnergy,
|
|
||||||
Math.max(0, component.available - industrial * component.industrialScale)
|
|
||||||
);
|
|
||||||
component.cityShortage = component.cityEnergy > 0
|
component.cityShortage = component.cityEnergy > 0
|
||||||
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
|
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
|
||||||
: 0;
|
: 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").supply += component.supply;
|
||||||
market.get("energy").demand += component.demand;
|
market.get("energy").demand += component.demand;
|
||||||
}
|
}
|
||||||
// A grid that cannot power its industry names the works left idle.
|
// A grid that cannot power its industry names the works left idle.
|
||||||
this._noteGridPowerShortages(ledger, components);
|
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) {
|
for (const entry of pendingFood) {
|
||||||
const component = ledger.get(entry.component);
|
const component = ledger.get(entry.component);
|
||||||
const scale = component ? component.industrialScale : 1;
|
const scale = component ? component.foodScale : 1;
|
||||||
const produced = entry.amount * scale;
|
const produced = entry.amount * scale;
|
||||||
if (produced <= 0) continue;
|
if (produced <= 0) continue;
|
||||||
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
|
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;
|
if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
|
||||||
const component = ledger.get(components.get(k));
|
const component = ledger.get(components.get(k));
|
||||||
const scale = component ? component.industrialScale : 1;
|
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;
|
if (produced <= 0) continue;
|
||||||
this._ensureResourceStock(k)[proto.resource] += produced;
|
this._ensureResourceStock(k)[proto.resource] += produced;
|
||||||
market.get(proto.resource).supply += 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
|
// The people eat, wear and spend. Shortfalls are matched against reachable
|
||||||
// stores -- home regions first, then foreign ones.
|
// 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,
|
// A region that could not feed its people is in famine: it tells the world,
|
||||||
// and its people start dying.
|
// and its people start dying.
|
||||||
@@ -1341,56 +1414,98 @@ export const resourceMethods = {
|
|||||||
// supply, filled in as the world is walked.
|
// supply, filled in as the world is walked.
|
||||||
_buildEnergyLedger(components) {
|
_buildEnergyLedger(components) {
|
||||||
const ledger = new Map();
|
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) {
|
for (const [k, id] of components) {
|
||||||
if (ledger.has(id)) continue;
|
if (ledger.has(id)) continue;
|
||||||
ledger.set(id, {
|
ledger.set(id, {
|
||||||
civ: this.territory.get(k),
|
civ: this.territory.get(k),
|
||||||
supply: 0,
|
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,
|
cityEnergy: 0,
|
||||||
converterEnergy: 0,
|
converterEnergy: 0,
|
||||||
foodEnergy: 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,
|
demand: 0,
|
||||||
available: 0,
|
available: 0,
|
||||||
industrialScale: 1,
|
industrialScale: 1,
|
||||||
|
foodScale: 1,
|
||||||
cityShortage: 0,
|
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) {
|
for (const city of this.cities) {
|
||||||
const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
|
const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
|
||||||
if (!component) continue;
|
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
|
// The city's buildings run on the grid too, so their operating power is
|
||||||
// charged to the same component.
|
// charged to the same component.
|
||||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||||
if (level <= 0) continue;
|
if (level <= 0) continue;
|
||||||
const proto = this.protoBuildings[Number(index)];
|
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) {
|
for (const [k, id] of this.tileImprovements) {
|
||||||
const proto = tileImprovementById(id);
|
const proto = tileImprovementById(id);
|
||||||
if (!proto) continue;
|
if (!proto) continue;
|
||||||
const component = ledger.get(components.get(k));
|
const component = ledger.get(components.get(k));
|
||||||
if (!component) continue;
|
if (!component) continue;
|
||||||
if (isResourceTileBuilding(proto) && proto.resource === "energy") {
|
if (isResourceTileBuilding(proto)) {
|
||||||
component.supply += proto.outputPerDay;
|
if (proto.resource === "energy") {
|
||||||
} else if (isResourceTileBuilding(proto)) {
|
const output = this.resourceBuildingOutputAt(k, proto);
|
||||||
component.converterEnergy += proto.energyPerDay || 0;
|
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;
|
component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy;
|
||||||
if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
|
|
||||||
}
|
}
|
||||||
return ledger;
|
return ledger;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Moves power between grids. A grid in deficit buys from the reachable grids
|
// 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
|
// 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
|
// it flows the same day. The seller's plants are paid for the power, and the
|
||||||
// regions receive the money.
|
// buyer's consumers carry the cost. No government is involved.
|
||||||
_tradeGridPower(ledger, components) {
|
_tradeGridPower(ledger, components) {
|
||||||
const price = this.getResourcePrice("energy");
|
const price = this.getResourcePrice("energy");
|
||||||
const surplus = new Map();
|
const surplus = new Map();
|
||||||
for (const [id, component] of ledger) {
|
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));
|
surplus.set(id, Math.max(0, component.supply - component.demand));
|
||||||
}
|
}
|
||||||
for (const [id, component] of ledger) {
|
for (const [id, component] of ledger) {
|
||||||
@@ -1404,31 +1519,70 @@ export const resourceMethods = {
|
|||||||
.filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0)
|
.filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0)
|
||||||
.sort((a, b) => a[1] - b[1]);
|
.sort((a, b) => a[1] - b[1]);
|
||||||
let needed = deficit;
|
let needed = deficit;
|
||||||
let spent = 0;
|
|
||||||
for (const [sid, routeEnergy] of suppliers) {
|
for (const [sid, routeEnergy] of suppliers) {
|
||||||
if (needed <= 0) break;
|
if (needed <= 0) break;
|
||||||
const available = surplus.get(sid) || 0;
|
const available = surplus.get(sid) || 0;
|
||||||
const take = Math.min(needed, available);
|
const take = Math.min(needed, available);
|
||||||
if (!(take > 0)) continue;
|
if (!(take > 0)) continue;
|
||||||
// The power costs its price plus the energy burned carrying it.
|
// The power costs its price plus the energy burned carrying it. A unit
|
||||||
const cost = take * (price + routeEnergy * price);
|
// 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);
|
const seller = ledger.get(sid);
|
||||||
this.budgets.set(component.civ, this.getBudget(component.civ) - cost);
|
this._payGridProducers(seller, cost, take);
|
||||||
this._recordBudgetCash(component.civ, "energy", -cost);
|
|
||||||
this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost);
|
|
||||||
if (seller.civ !== component.civ) {
|
if (seller.civ !== component.civ) {
|
||||||
this._creditRegions(seller.civ, cost);
|
|
||||||
this._recordExternalBuy(component.civ, "energy", take, cost);
|
this._recordExternalBuy(component.civ, "energy", take, cost);
|
||||||
this._recordExternalSell(seller.civ, "energy", take, cost);
|
this._recordExternalSell(seller.civ, "energy", take, cost);
|
||||||
} else {
|
|
||||||
this._creditRegions(seller.civ, cost);
|
|
||||||
}
|
}
|
||||||
surplus.set(sid, available - take);
|
surplus.set(sid, available - take);
|
||||||
component.imports += take;
|
component.imports += take;
|
||||||
|
component.importCost += cost;
|
||||||
|
seller.exports += take;
|
||||||
needed -= 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;
|
return reach;
|
||||||
},
|
},
|
||||||
|
|
||||||
_consumeCityResources(components, market) {
|
_consumeCityResources(components, market, nodes = null) {
|
||||||
const nodes = this._resourceNodes();
|
nodes = nodes || this._resourceNodes();
|
||||||
// How much of each supplier's surplus earlier buyers have already claimed
|
// How much of each supplier's surplus earlier buyers have already claimed
|
||||||
// today, so a contested surplus goes to whoever bids highest.
|
// today, so a contested surplus goes to whoever bids highest.
|
||||||
this._nodeClaims = new Map();
|
this._nodeClaims = new Map();
|
||||||
@@ -1679,6 +1833,10 @@ export const resourceMethods = {
|
|||||||
supplier.stock[id] -= bought;
|
supplier.stock[id] -= bought;
|
||||||
cityStore[id] += bought;
|
cityStore[id] += bought;
|
||||||
remaining -= 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({
|
this.resourceLinks.push({
|
||||||
from: [supplier.coords.x, supplier.coords.y],
|
from: [supplier.coords.x, supplier.coords.y],
|
||||||
to: [city.coords.x, city.coords.y],
|
to: [city.coords.x, city.coords.y],
|
||||||
@@ -1689,9 +1847,15 @@ export const resourceMethods = {
|
|||||||
return amount - remaining;
|
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) {
|
_paySupplier(node, code, amount) {
|
||||||
if (!(amount > 0)) return;
|
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);
|
const city = node.city || this._regionCityNear(node.civ, node.coords);
|
||||||
if (city) this._creditRegionCash(city, code, amount);
|
if (city) this._creditRegionCash(city, code, amount);
|
||||||
},
|
},
|
||||||
@@ -1725,17 +1889,6 @@ export const resourceMethods = {
|
|||||||
return best;
|
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
|
// 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.
|
// two-tile road delivery burns. Used to value materials, not to source them.
|
||||||
_marketUnitCost(id) {
|
_marketUnitCost(id) {
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export const serializationMethods = {
|
|||||||
taxes: null,
|
taxes: null,
|
||||||
taxTake: { sales: 0, export: 0, import: 0 },
|
taxTake: { sales: 0, export: 0, import: 0 },
|
||||||
commodityInflation: 0,
|
commodityInflation: 0,
|
||||||
|
privateBuildingCash: 0,
|
||||||
|
productionBuildings: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const headline = this._civHeadline(viewerCiv);
|
const headline = this._civHeadline(viewerCiv);
|
||||||
@@ -197,6 +199,10 @@ export const serializationMethods = {
|
|||||||
exchangeRates: this._serializeExchangeRates(viewerCiv),
|
exchangeRates: this._serializeExchangeRates(viewerCiv),
|
||||||
centralBank: this._serializeCentralBank(viewerCiv),
|
centralBank: this._serializeCentralBank(viewerCiv),
|
||||||
privateCash: this.getPrivateSectorCash(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
|
// Where every unit of the viewer's currency is stored: its treasury, its
|
||||||
// regions, and foreign regions and central banks that hold it.
|
// regions, and foreign regions and central banks that hold it.
|
||||||
currencyHoldings: this.getCurrencyHoldings(viewerCiv),
|
currencyHoldings: this.getCurrencyHoldings(viewerCiv),
|
||||||
@@ -334,6 +340,7 @@ export const serializationMethods = {
|
|||||||
id,
|
id,
|
||||||
this.tileImprovementOwner.get(k) ?? -1,
|
this.tileImprovementOwner.get(k) ?? -1,
|
||||||
this.tileImprovementHp.get(k) || 0,
|
this.tileImprovementHp.get(k) || 0,
|
||||||
|
this.resourceBuildingLevel(k),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -78,50 +78,6 @@ export const statusMethods = {
|
|||||||
return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea"));
|
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) {
|
_statusHpLossPerDay(def, inCity) {
|
||||||
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
|
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
|
||||||
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
|
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { key, parseKey } from "../hex.js";
|
import { key, parseKey } from "../hex.js";
|
||||||
import { ECONOMY } from "../data/economy.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 { TRENCH } from "../data/combat.js";
|
||||||
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
||||||
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
|
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
|
||||||
@@ -54,7 +54,7 @@ export const warfareMethods = {
|
|||||||
if (!tile || tile.terrainClass !== "Land") return false;
|
if (!tile || tile.terrainClass !== "Land") return false;
|
||||||
if (this.civAt(coords) !== civ) return false;
|
if (this.civAt(coords) !== civ) return false;
|
||||||
if (this.tileImprovements.has(k)) 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.
|
// Resource producers feed the map, not a city: they stand on open land.
|
||||||
if (proto.resource && this.cityAt(coords)) return false;
|
if (proto.resource && this.cityAt(coords)) return false;
|
||||||
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
||||||
@@ -89,6 +89,7 @@ export const warfareMethods = {
|
|||||||
if (this.tileImprovements.delete(k)) {
|
if (this.tileImprovements.delete(k)) {
|
||||||
this.tileImprovementOwner.delete(k);
|
this.tileImprovementOwner.delete(k);
|
||||||
this.tileImprovementHp.delete(k);
|
this.tileImprovementHp.delete(k);
|
||||||
|
this._removeBuildingAgent(k);
|
||||||
removed = true;
|
removed = true;
|
||||||
}
|
}
|
||||||
if (!removed) return false;
|
if (!removed) return false;
|
||||||
@@ -128,26 +129,13 @@ export const warfareMethods = {
|
|||||||
this.tileImprovements.set(k, work.id);
|
this.tileImprovements.set(k, work.id);
|
||||||
this.tileImprovementOwner.set(k, civ);
|
this.tileImprovementOwner.set(k, civ);
|
||||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
|
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
|
||||||
|
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
|
||||||
this._tileImprovementVersion += 1;
|
this._tileImprovementVersion += 1;
|
||||||
this._gdpPerCapitaCache.delete(k);
|
this._gdpPerCapitaCache.delete(k);
|
||||||
this._visibilityDirty = true;
|
this._visibilityDirty = true;
|
||||||
this._emitChanged();
|
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,
|
// The extra defense a tile's fortifications and trenches give a defender,
|
||||||
// against a melee or artillery attacker.
|
// against a melee or artillery attacker.
|
||||||
_tileFortificationMultiplier(coords, attackerProto) {
|
_tileFortificationMultiplier(coords, attackerProto) {
|
||||||
@@ -193,6 +181,7 @@ export const warfareMethods = {
|
|||||||
this.tileImprovements.delete(k);
|
this.tileImprovements.delete(k);
|
||||||
this.tileImprovementOwner.delete(k);
|
this.tileImprovementOwner.delete(k);
|
||||||
this.tileImprovementHp.delete(k);
|
this.tileImprovementHp.delete(k);
|
||||||
|
this._removeBuildingAgent(k);
|
||||||
this._tileImprovementVersion += 1;
|
this._tileImprovementVersion += 1;
|
||||||
this._emitChanged();
|
this._emitChanged();
|
||||||
return true;
|
return true;
|
||||||
@@ -285,7 +274,9 @@ export const warfareMethods = {
|
|||||||
// Finishes any boarding: at zero hours the infantry leave the map and become
|
// Finishes any boarding: at zero hours the infantry leave the map and become
|
||||||
// the launch's cargo, and are carried until put ashore.
|
// the launch's cargo, and are carried until put ashore.
|
||||||
_tickEmbark() {
|
_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;
|
if (!unit.embarking) continue;
|
||||||
unit.embarking.hoursLeft -= 1;
|
unit.embarking.hoursLeft -= 1;
|
||||||
if (unit.embarking.hoursLeft > 0) continue;
|
if (unit.embarking.hoursLeft > 0) continue;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { ECONOMY } from "../data/economy.js";
|
import { ECONOMY } from "../data/economy.js";
|
||||||
import { RESOURCE_RULES } from "../data/resources.js";
|
import { RESOURCE_RULES } from "../data/resources.js";
|
||||||
import { ROADS } from "../data/roads.js";
|
|
||||||
import { transportImprovement } from "../data/improvements.js";
|
import { transportImprovement } from "../data/improvements.js";
|
||||||
import { buildRoadNetwork } from "../roads.js";
|
import { buildRoadNetwork } from "../roads.js";
|
||||||
import { MapTopology, key, parseKey } from "../hex.js";
|
import { MapTopology, key, parseKey } from "../hex.js";
|
||||||
|
|||||||
+35
-9
@@ -80,6 +80,41 @@ export function foodMonthlyTarget(population) {
|
|||||||
RESOURCE_RULES.foodMonthlyBuffer;
|
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.
|
// A city's baseline electric appetite, kWh a day, from its yearly GDP.
|
||||||
export function cityEnergyPerDay(gdp) {
|
export function cityEnergyPerDay(gdp) {
|
||||||
return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
|
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);
|
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 --
|
// ----------------------------------------------------------------- market --
|
||||||
|
|
||||||
// Moves a price toward what the day's world-wide supply and demand imply. A
|
// 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));
|
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
|
// 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.
|
// level). It grows only with the level, never with the nation's GDP.
|
||||||
export function buildingBuildCost(proto, level) {
|
export function buildingBuildCost(proto, level) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { TestCase } from "./framework/test_case.js";
|
import { TestCase } from "./framework/test_case.js";
|
||||||
import { smallState } from "./framework/helpers.js";
|
import { smallState } from "./framework/helpers.js";
|
||||||
import { parseKey, key } from "../shared/hex.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;
|
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 { TestCase } from "./framework/test_case.js";
|
||||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||||
import { GameScreen } from "../client/js/game_screen.js";
|
import { GameScreen } from "../client/js/game_screen.js";
|
||||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
||||||
import { hoursInNextMonth } from "../shared/game_clock.js";
|
import { hoursInNextMonth } from "../shared/game_clock.js";
|
||||||
import { compact } from "../shared/text_format.js";
|
import { compact } from "../shared/text_format.js";
|
||||||
import { tileImprovementResourceCost } from "../shared/resources.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();
|
const env = await setupDom();
|
||||||
try {
|
try {
|
||||||
const network = stubNetwork();
|
const network = stubNetwork();
|
||||||
const screen = new GameScreen(network, makeConfig());
|
const screen = new GameScreen(network, makeConfig());
|
||||||
screen.enter();
|
screen.enter();
|
||||||
const state = smallState();
|
const state = smallState();
|
||||||
state.budgets.set(0, 0);
|
|
||||||
const home = state.units.find((u) => u.civ === 0);
|
const home = state.units.find((u) => u.civ === 0);
|
||||||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||||||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||||||
const goal = adjacentLand(state, home);
|
const goal = adjacentLand(state, home);
|
||||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
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");
|
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
|
||||||
this.assertEqual(network.orders.length, 0, "no order is sent without the budget");
|
this.assertEqual(network.orders.length, 0, "no order is sent without the munitions");
|
||||||
this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget");
|
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();
|
screen.leave();
|
||||||
} finally {
|
} finally {
|
||||||
teardownDom(env);
|
teardownDom(env);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestCase } from "./framework/test_case.js";
|
import { TestCase } from "./framework/test_case.js";
|
||||||
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
||||||
import { key } from "../shared/hex.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 { tileFoodOutput } from "../shared/resources.js";
|
||||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.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 { TestCase } from "./framework/test_case.js";
|
||||||
import { smallState, cityOf } from "./framework/helpers.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";
|
import { MONEY } from "../shared/data/economy.js";
|
||||||
|
|
||||||
export class MoneyTest extends TestCase {
|
export class MoneyTest extends TestCase {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestCase } from "./framework/test_case.js";
|
import { TestCase } from "./framework/test_case.js";
|
||||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||||
import { MapView } from "../client/js/map_view.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 { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
||||||
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
|
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
TECHNOLOGIES,
|
TECHNOLOGIES,
|
||||||
TERRAIN_TILES,
|
TERRAIN_TILES,
|
||||||
MAP_CONFIG,
|
MAP_CONFIG,
|
||||||
ECONOMY,
|
|
||||||
TAXES,
|
TAXES,
|
||||||
BATTLE,
|
BATTLE,
|
||||||
effectValue,
|
effectValue,
|
||||||
|
|||||||
+1
-22
@@ -1,7 +1,7 @@
|
|||||||
import { TestCase } from "./framework/test_case.js";
|
import { TestCase } from "./framework/test_case.js";
|
||||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||||
import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.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 { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
|
||||||
import { MapView } from "../client/js/map_view.js";
|
import { MapView } from "../client/js/map_view.js";
|
||||||
|
|
||||||
@@ -232,24 +232,3 @@ function findCoastalClearCity(state, civ) {
|
|||||||
}
|
}
|
||||||
return null;
|
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