Made queued buildings gather their materials and rise ten times faster
City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar. Buildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "c8260b2",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Better tile building management",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "7c28c45",
|
||||
"date": "2026-09-22",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Gave regions their own economies and removed the global market",
|
||||
"body": "- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.\n- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.\n- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.\n- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.\n- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.\n- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption."
|
||||
},
|
||||
{
|
||||
"hash": "e7ee5db",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Added the resource economy, its works and their icons",
|
||||
"body": "- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.\n- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.\n- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export const panelMethods = {
|
||||
$el.append(
|
||||
$("<div class='site-material-label stalled'></div>").text(
|
||||
"The market is above the budgeted prices. " +
|
||||
"The budget rises 10% a day until the materials can be bought."
|
||||
"The budget rises 1% a day, doubling each day, until the materials can be bought."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
@@ -1094,6 +1094,15 @@ export const panelMethods = {
|
||||
const building = entry.kind === "building";
|
||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||
const name = proto ? proto.name : (building ? "Building" : "Unit");
|
||||
if (building && entry.phase === "materials") {
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`),
|
||||
$bar
|
||||
);
|
||||
return;
|
||||
}
|
||||
const verb = building ? "Building" : "Training";
|
||||
const total = Math.max(entry.totalHours || 0, 0.0001);
|
||||
const elapsed = entry.elapsedHours || 0;
|
||||
@@ -1225,7 +1234,7 @@ export const panelMethods = {
|
||||
for (const improvement of TILE_IMPROVEMENTS) {
|
||||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === improvement.id);
|
||||
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const title = standing && !built
|
||||
? `${improvement.name} — another improvement already stands here`
|
||||
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
@@ -1297,7 +1306,7 @@ export const panelMethods = {
|
||||
for (const producer of RESOURCE_BUILDINGS) {
|
||||
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === producer.id);
|
||||
const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed);
|
||||
const buildHours = ECONOMY.buildingHours(gdp, producer.buildCost) / (1 + speed);
|
||||
// Some producers are locked behind a technology (the fusion plant).
|
||||
const techIndex = producer.requiresTechnology
|
||||
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
|
||||
@@ -1847,6 +1856,13 @@ export const panelMethods = {
|
||||
const building = entry.kind === "building";
|
||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||
const name = proto ? proto.name : (building ? "building" : "unit");
|
||||
// A building order spends its first days buying its steel and high-tech, so
|
||||
// show that gathering rather than a construction clock that has not started.
|
||||
if (building && entry.phase === "materials") {
|
||||
this._trainingCityId = this.selectedCityId;
|
||||
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
|
||||
return;
|
||||
}
|
||||
const verb = building ? "Building" : "Training";
|
||||
if (this._trainingCityId !== this.selectedCityId) {
|
||||
this._trainingCityId = this.selectedCityId;
|
||||
@@ -1856,6 +1872,20 @@ export const panelMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// How far a building order has got through gathering its material bill, as the
|
||||
// average of each storable material's share bought. 1 when nothing is needed.
|
||||
_gatherFraction(entry) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const id of ["steel", "hightech"]) {
|
||||
const need = (entry.needed && entry.needed[id]) || 0;
|
||||
if (!(need > 0)) continue;
|
||||
sum += Math.min(1, ((entry.bought && entry.bought[id]) || 0) / need);
|
||||
count += 1;
|
||||
}
|
||||
return count > 0 ? sum / count : 1;
|
||||
},
|
||||
|
||||
// How many levels of each building are already waiting in the city's shared
|
||||
// production queue, so the buildings tab prices the next order correctly.
|
||||
_queuedBuildingLevels(cityId) {
|
||||
|
||||
@@ -391,10 +391,15 @@ export class CityModal {
|
||||
for (const index of group.indices) {
|
||||
const entry = queue[index];
|
||||
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
|
||||
cumulative += Math.max(entry.totalHours - elapsed, 0);
|
||||
// A building still gathering its materials has not started building, so
|
||||
// it adds no time to the queue's readiness estimate.
|
||||
const gather = entry.kind === "building" && entry.phase === "materials";
|
||||
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
|
||||
if (index === group.indices[0]) firstReady = cumulative;
|
||||
}
|
||||
const count = group.indices.length;
|
||||
const gathering = queue[group.indices[0]].kind === "building" &&
|
||||
queue[group.indices[0]].phase === "materials";
|
||||
const name = group.kind === "building"
|
||||
? this.buildingNames.get(group.protoIndex) || "Building"
|
||||
: this.protoNames.get(group.protoIndex) || "Unit";
|
||||
@@ -404,9 +409,11 @@ export class CityModal {
|
||||
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
|
||||
$info.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
count > 1
|
||||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||||
: `Ready in ${hours(cumulative)}`
|
||||
gathering
|
||||
? "Gathering materials"
|
||||
: count > 1
|
||||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||||
: `Ready in ${hours(cumulative)}`
|
||||
)
|
||||
);
|
||||
const $actions = $("<div class='actions'></div>");
|
||||
@@ -435,6 +442,15 @@ export class CityModal {
|
||||
this.$trainProgress.css("width", "100%");
|
||||
}
|
||||
|
||||
// The selected city's head of queue is a building still buying its steel and
|
||||
// high-tech: show how far that gathering has got, with no hours figure since
|
||||
// the construction clock has not started.
|
||||
setGatheringProgress(name, fraction = 0) {
|
||||
this.$trainLabel.text(`Gathering materials for ${name}…`);
|
||||
this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`);
|
||||
this.$trainStatus.removeClass("hidden");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- buildings --
|
||||
|
||||
_makeBuildingRow(proto, index) {
|
||||
@@ -495,7 +511,7 @@ export class CityModal {
|
||||
const parts = [];
|
||||
if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
|
||||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||||
const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor();
|
||||
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
|
||||
parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`);
|
||||
const requirement = this._buildRequirement(row.proto);
|
||||
if (requirement) parts.push(requirement);
|
||||
|
||||
@@ -86,6 +86,10 @@ export const ECONOMY = {
|
||||
capitalTileProductionMultiplier: 2.5,
|
||||
citySurroundingProductionBonus: 0.3,
|
||||
gdpPerProductionPoint: 100_000,
|
||||
// Building and tile-improvement construction runs this much faster than
|
||||
// training: a building takes a tenth of the hours a unit of the same cost
|
||||
// would. Unit training is untouched.
|
||||
buildingTimeMultiplier: 0.1,
|
||||
// A nation opens the game on 1 January 2000 with a tenth of its starting GDP.
|
||||
startingBudgetFraction: 0.1,
|
||||
startingBudget: 1_000_000_000,
|
||||
@@ -106,6 +110,12 @@ export const ECONOMY = {
|
||||
return cost / capacity;
|
||||
},
|
||||
|
||||
// The hours a building or tile improvement of `cost` takes to rise, ten times
|
||||
// quicker than training something of the same cost.
|
||||
buildingHours(gdp, cost) {
|
||||
return this.trainingHours(gdp, cost) * this.buildingTimeMultiplier;
|
||||
},
|
||||
|
||||
populationGrowthFactor(hoursPerYear, hours = 1) {
|
||||
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
|
||||
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
|
||||
|
||||
@@ -923,12 +923,22 @@ export const economyMethods = {
|
||||
|
||||
_tickTraining() {
|
||||
const completed = [];
|
||||
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
for (const [cityId, queue] of this.training) {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city) {
|
||||
completed.push(cityId);
|
||||
continue;
|
||||
}
|
||||
// A queued building first gathers its materials, once a day, exactly as a
|
||||
// tile improvement's construction site does; only then does its
|
||||
// construction time begin.
|
||||
for (const entry of queue) {
|
||||
if (entry.kind !== "building" || entry.phase !== "materials") continue;
|
||||
if (entry.lastGatherDay === day) continue;
|
||||
entry.lastGatherDay = day;
|
||||
this._gatherBuildEntry(city, entry);
|
||||
}
|
||||
// A city runs one production line per kind plus whatever its Barracks
|
||||
// (units) and Shipyards (buildings) add. Each line works one entry at full
|
||||
// capacity, so a higher-level city produces several things at once; the
|
||||
@@ -970,6 +980,12 @@ export const economyMethods = {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// A building still gathering its materials is not on a construction line
|
||||
// yet, so it neither works nor blocks the ones that are.
|
||||
if (entry.phase === "materials") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const proto = this._queueEntryProto(entry);
|
||||
if (!proto) {
|
||||
queue.splice(i, 1);
|
||||
@@ -1006,9 +1022,11 @@ export const economyMethods = {
|
||||
return index >= 0 && index < this.protoUnits.length ? this.protoUnits[index] : null;
|
||||
},
|
||||
|
||||
// Raises one level of a completed building. The cost was paid when the order
|
||||
// was placed, so this only changes the city and invalidates the GDP cache.
|
||||
// Raises one level of a completed building. The money cost was paid when the
|
||||
// order was placed and the materials gathered as it ran, so this only returns
|
||||
// any unspent material budget, changes the city and invalidates the GDP cache.
|
||||
_completeBuilding(city, entry) {
|
||||
this._releaseEntryBudget(city.civ, entry);
|
||||
const level = this.getCityBuildingLevel(city, entry.protoIndex);
|
||||
city.buildings[entry.protoIndex] = level + 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
|
||||
+28
-10
@@ -7,7 +7,7 @@ import { GOVERNMENTS } from "../data/governments.js";
|
||||
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
||||
import { transportImprovement } from "../data/improvements.js";
|
||||
import { buildingBuildCost } from "../rules.js";
|
||||
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||
import { TRAINING_QUEUE_LIMIT, HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
export const orderMethods = {
|
||||
// Adds one unit to the city's production queue. The queue is shared with
|
||||
@@ -73,10 +73,12 @@ export const orderMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Queues one more level of a building in the city's production queue. Like a
|
||||
// unit, the level is paid for up front from the budget and only rises once
|
||||
// its production time has elapsed. Repeated orders stack levels: the second
|
||||
// queued level is priced off the level the first will leave behind.
|
||||
// Queues one more level of a building in the city's production queue. The
|
||||
// level's money cost is paid up front, but its steel and high-tech are
|
||||
// gathered over the days that follow -- exactly as a tile improvement does --
|
||||
// and only then does the construction time begin. Repeated orders stack
|
||||
// levels: the second queued level is priced off the level the first will
|
||||
// leave behind.
|
||||
requestBuild(cityId, protoIndex, free = false) {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
||||
@@ -113,23 +115,39 @@ export const orderMethods = {
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
||||
if (capacity <= 0) return false;
|
||||
if (!this._spendBudget(city.civ, cost, "construction")) return false;
|
||||
// The level's material bill is not bought up front any more: like a tile
|
||||
// improvement, the order reserves a budget and gathers the steel and
|
||||
// high-tech over the days before construction can begin.
|
||||
const materials = this.constructionResourceCost(proto, level, cost);
|
||||
if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) {
|
||||
this._refundBudget(city.civ, cost, "construction");
|
||||
return false;
|
||||
}
|
||||
const needed = {
|
||||
steel: Math.max(0, materials.steel || 0),
|
||||
hightech: Math.max(0, materials.hightech || 0),
|
||||
};
|
||||
const reserved = needed.steel * this.getResourcePrice("steel") +
|
||||
needed.hightech * this.getResourcePrice("hightech");
|
||||
this._reserveSiteBudget(city.civ, reserved, "construction");
|
||||
const entry = {
|
||||
kind: "building",
|
||||
protoIndex,
|
||||
level,
|
||||
capacity,
|
||||
phase: "materials",
|
||||
budget: reserved,
|
||||
prices: this._lockedUnitPrices(),
|
||||
needed,
|
||||
bought: { steel: 0, hightech: 0 },
|
||||
elapsedHours: 0,
|
||||
totalHours: (cost / capacity) *
|
||||
totalHours: ECONOMY.buildingHours(this.getPlayerGdp(city.civ), cost) *
|
||||
this.constructionApprovalMultiplier(city) *
|
||||
this.constructionSpeedMultiplier(city.civ),
|
||||
lastGatherDay: -1,
|
||||
};
|
||||
if (queue) queue.push(entry);
|
||||
else this.training.set(cityId, [entry]);
|
||||
// Make the first purchase at once, so a well-supplied city starts building
|
||||
// right away instead of waiting for the first day boundary.
|
||||
entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
this._gatherBuildEntry(city, entry);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
+90
-17
@@ -10,11 +10,12 @@
|
||||
//
|
||||
// A site stalls when a whole day passes without buying a needed material at the
|
||||
// locked price (the market moved against it). Rather than waiting for a manual
|
||||
// top-up, it raises its budgeted prices -- and the money behind them -- by a
|
||||
// tenth a day until it can buy; only when no supplier is within reach at all
|
||||
// does it keep a lasting warning. The owner may still cancel the work, losing
|
||||
// everything already spent. Production buildings that finish become private
|
||||
// agents and step through the same phases again when they upgrade.
|
||||
// top-up, it raises its budgeted prices -- and the money behind them -- each
|
||||
// day, 1% at first and doubling daily, until it can buy; only when no supplier
|
||||
// is within reach at all does it keep a lasting warning. The owner may still
|
||||
// cancel the work, losing everything already spent. Production buildings that
|
||||
// finish become private agents and step through the same phases again when they
|
||||
// upgrade.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
@@ -29,9 +30,10 @@ import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
|
||||
// locked price is not bought from: the site waits for the market or raises its
|
||||
// own budget.
|
||||
const PRICE_TOLERANCE = 0.01;
|
||||
// Each day a stalled site cannot buy, its budgeted prices and the matching
|
||||
// money rise by this factor, so it bids its way up to the market unaided.
|
||||
const BUDGET_DAILY_GROWTH = 1.1;
|
||||
// The first day a stalled site cannot buy, its budgeted prices and the matching
|
||||
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
|
||||
// it bids its way up to the market unaided.
|
||||
const BUDGET_DAILY_RISE = 0.01;
|
||||
const MATERIAL_IDS = ["steel", "hightech"];
|
||||
const RESOURCE_IDS = ["steel", "hightech", "energy"];
|
||||
|
||||
@@ -126,10 +128,8 @@ export const siteMethods = {
|
||||
: tileImprovementResourceCost(proto, buildCost);
|
||||
const needed = emptyResources();
|
||||
for (const id of RESOURCE_IDS) needed[id] = Math.max(0, materials[id] || 0);
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(civ));
|
||||
const baseHours = kind === "upgrade"
|
||||
? (capacity > 0 ? Math.max(1, buildCost / capacity) : 1)
|
||||
: ECONOMY.trainingHours(this.getPlayerGdp(civ), buildCost);
|
||||
const gdp = this.getPlayerGdp(civ);
|
||||
const baseHours = ECONOMY.buildingHours(gdp, buildCost);
|
||||
const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ));
|
||||
const route = this._siteCityPath(civ, coords);
|
||||
const site = {
|
||||
@@ -148,6 +148,8 @@ export const siteMethods = {
|
||||
cityId: route.cityId,
|
||||
path: route.path,
|
||||
stalled: false,
|
||||
// Consecutive stalled days, driving the doubling budget rise.
|
||||
budgetRaises: 0,
|
||||
lastBuyDay: -1,
|
||||
};
|
||||
this.constructionSites.set(k, site);
|
||||
@@ -229,6 +231,7 @@ export const siteMethods = {
|
||||
const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9);
|
||||
if (!stillMissing) {
|
||||
site.stalled = false;
|
||||
site.budgetRaises = 0;
|
||||
site.phase = "construction";
|
||||
site.elapsedHours = 0;
|
||||
this._emitChanged();
|
||||
@@ -239,17 +242,23 @@ export const siteMethods = {
|
||||
site.stalled = stalled;
|
||||
this._emitChanged();
|
||||
}
|
||||
if (!stalled) site.budgetRaises = 0;
|
||||
},
|
||||
|
||||
// A day that bought nothing while a supplier is within reach raises the site's
|
||||
// budgeted prices by a tenth and moves the matching money out of the treasury,
|
||||
// then tries again. A supplier price above the raised cap simply stalls it
|
||||
// once more and the budget climbs another tenth tomorrow. When no supplier can
|
||||
// reach the site at any price the rise cannot help, so it is left alone.
|
||||
// budgeted prices and moves the matching money out of the treasury, then tries
|
||||
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
|
||||
// 8%, ...), so a stubborn price is met within a few days. A supplier still
|
||||
// above the raised cap simply stalls it once more; the count resets as soon as
|
||||
// it buys. When no supplier can reach the site at any price the rise cannot
|
||||
// help, so it is left alone.
|
||||
_raiseSiteBudgetIfStalled(site) {
|
||||
if (!site.stalled || site.phase !== "materials") return;
|
||||
if (this._siteUnreachableMaterials(site).length > 0) return;
|
||||
for (const id of RESOURCE_IDS) site.prices[id] *= BUDGET_DAILY_GROWTH;
|
||||
const raises = site.budgetRaises || 0;
|
||||
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
|
||||
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
|
||||
site.budgetRaises = raises + 1;
|
||||
const add = this._siteTopUp(site, site.prices);
|
||||
this._reserveSiteBudget(site.civ, add);
|
||||
site.budget += add;
|
||||
@@ -325,6 +334,70 @@ export const siteMethods = {
|
||||
return payer;
|
||||
},
|
||||
|
||||
// ------------------------------------------------ city building orders --
|
||||
|
||||
// One day of a queued city building's material phase. Like a construction
|
||||
// site it buys whatever of its steel and high-tech bill it still lacks, at the
|
||||
// prices locked in when the order was placed: the nation's own stores first,
|
||||
// then the reachable suppliers. It only flips to construction once the whole
|
||||
// bill is gathered; a day that buys nothing leaves it stalled.
|
||||
_gatherBuildEntry(city, entry) {
|
||||
const payer = this._entryPayer(city, entry);
|
||||
const remaining = { steel: 0, hightech: 0 };
|
||||
for (const id of MATERIAL_IDS) {
|
||||
remaining[id] = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0));
|
||||
}
|
||||
// The nation's own cities are bought from first, nearest to the building.
|
||||
const drawn = this._drawFromNearest(city.civ, city.coords, remaining, payer);
|
||||
for (const id of MATERIAL_IDS) {
|
||||
if (drawn[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + drawn[id];
|
||||
}
|
||||
for (const id of MATERIAL_IDS) {
|
||||
const missing = (entry.needed[id] || 0) - (entry.bought[id] || 0);
|
||||
if (!(missing > 1e-9)) continue;
|
||||
const temp = emptyResources();
|
||||
this._procureFromNeighbours(city, id, missing, temp, this._resourceNodes(), {
|
||||
payer,
|
||||
allowSea: this._cityMarketAccess(city),
|
||||
});
|
||||
if (temp[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + temp[id];
|
||||
}
|
||||
const stillMissing = MATERIAL_IDS.some(
|
||||
(id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9
|
||||
);
|
||||
if (!stillMissing) entry.phase = "construction";
|
||||
entry.stalled = stillMissing;
|
||||
return !stillMissing;
|
||||
},
|
||||
|
||||
// A payer for a queued building: its reserved material budget first, then the
|
||||
// public treasury, so an overrun is public money.
|
||||
_entryPayer(city, entry) {
|
||||
const payer = { total: 0, overflow: 0 };
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
const fromBudget = Math.min(entry.budget || 0, cost);
|
||||
entry.budget = (entry.budget || 0) - fromBudget;
|
||||
const rest = cost - fromBudget;
|
||||
if (rest > 0) {
|
||||
this.budgets.set(city.civ, this.getBudget(city.civ) - rest);
|
||||
this._recordBudgetCash(city.civ, "construction", -rest);
|
||||
payer.overflow += rest;
|
||||
}
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
},
|
||||
|
||||
// Hands a finished order's unspent material budget back to the treasury. The
|
||||
// reserved money was never paid to the regions, so it is not clawed back.
|
||||
_releaseEntryBudget(civ, entry) {
|
||||
if (!(entry.budget > 0)) return;
|
||||
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
|
||||
this._recordBudgetCash(civ, "construction", entry.budget);
|
||||
entry.budget = 0;
|
||||
},
|
||||
|
||||
// -------------------------------------------------- completion --
|
||||
|
||||
_completeConstructionSite(site) {
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeSite(state, coords, overrides = {}) {
|
||||
cityId: null,
|
||||
path: [],
|
||||
stalled: false,
|
||||
budgetRaises: 0,
|
||||
lastBuyDay: -1,
|
||||
...overrides,
|
||||
};
|
||||
@@ -161,14 +162,46 @@ export class ConstructionSiteTest extends TestCase {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.1,
|
||||
locked * 1.01,
|
||||
1e-6,
|
||||
"the budgeted price rises a tenth"
|
||||
"the first day's budgeted price rises 1%"
|
||||
);
|
||||
this.assertGreater(site.budget, 0, "the matching money moved into the site");
|
||||
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
|
||||
}
|
||||
|
||||
test_the_budget_rise_doubles_every_day() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 0,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
// A supplier far out of reach in price keeps the site stalling as it climbs.
|
||||
site.prices.steel = state._marketUnitCost("steel") * 0.001;
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
|
||||
const nodes = state._resourceNodes();
|
||||
const suppliers = state._nearbySuppliers(city, "steel", nodes);
|
||||
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
|
||||
suppliers[0].stock.steel = 1e9;
|
||||
state.budgets.set(0, 1e15);
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "the locked price is too low to buy at");
|
||||
// The rises compound: 1%, then 2%, then 4%, ...
|
||||
let prev = site.prices.steel;
|
||||
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
const expected = prev * (1 + 0.01 * 2 ** day);
|
||||
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
|
||||
prev = site.prices.steel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
@@ -195,9 +228,9 @@ export class ConstructionSiteTest extends TestCase {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.1,
|
||||
locked * 1.01,
|
||||
1e-6,
|
||||
"the budgeted price rose a tenth"
|
||||
"the first day's budgeted price rises 1%"
|
||||
);
|
||||
// Keep letting the budget climb; it must eventually reach the supplier.
|
||||
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
|
||||
|
||||
@@ -44,6 +44,23 @@ export class EconomyTest extends TestCase {
|
||||
this.assertEqual(config({ gdpPerProductionPoint: 0 }).trainingHours(1000, 50), 0);
|
||||
}
|
||||
|
||||
test_building_hours_are_a_tenth_of_training() {
|
||||
const cfg = config({ gdpPerProductionPoint: 100 });
|
||||
this.assertApprox(cfg.buildingHours(1000, 50), 0.5);
|
||||
this.assertApprox(
|
||||
cfg.buildingHours(1000, 50),
|
||||
cfg.trainingHours(1000, 50) / 10,
|
||||
1e-9,
|
||||
"construction is ten times faster than training the same cost"
|
||||
);
|
||||
}
|
||||
|
||||
test_building_hours_follow_the_multiplier() {
|
||||
const cfg = config({ gdpPerProductionPoint: 100, buildingTimeMultiplier: 0.5 });
|
||||
this.assertApprox(cfg.buildingHours(1000, 50), 2.5);
|
||||
this.assertEqual(config({ gdpPerProductionPoint: 0 }).buildingHours(1000, 50), 0);
|
||||
}
|
||||
|
||||
test_trade_tax_defaults() {
|
||||
this.assertApprox(TAXES.defaultSales, 0.2);
|
||||
this.assertApprox(TAXES.defaultExport, 0.05);
|
||||
|
||||
@@ -1746,7 +1746,7 @@ export class GameScreenTest extends TestCase {
|
||||
.map((_, el) => env.$(el).text())
|
||||
.get()
|
||||
.join("|");
|
||||
this.assertTrue(labels.includes("10%"), `the automatic rise is explained: ${labels}`);
|
||||
this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`);
|
||||
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
|
||||
this.assertEqual(
|
||||
env.$("#tile-works .site-warning").length,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/help
|
||||
import { key } from "../shared/hex.js";
|
||||
import { EFFECT_RESEARCH } from "../shared/data.js";
|
||||
import { tileFoodOutput } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
@@ -182,6 +182,51 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied");
|
||||
}
|
||||
|
||||
test_a_building_order_gathers_its_materials_before_building() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const city = cityOf(state, 0);
|
||||
// Drain every own store and cut the network, so nothing can be gathered.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ !== 0) continue;
|
||||
const store = state.getCityResourceStock(own);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
state.roads.clear();
|
||||
state.railways.clear();
|
||||
this.assertTrue(state.requestBuild(city.id, index));
|
||||
const queue = state.getTraining(city.id);
|
||||
this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds");
|
||||
this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started");
|
||||
// Supply the steel; the next day the order can buy and start building.
|
||||
state.getCityResourceStock(city).steel = 1.0e9;
|
||||
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
|
||||
this.assertEqual(queue[0].phase, "construction", "the bill is gathered");
|
||||
this.advanceUntilIdle(state);
|
||||
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
|
||||
}
|
||||
|
||||
test_a_stalled_building_order_waits_for_materials() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const city = cityOf(state, 0);
|
||||
// Nothing anywhere to buy: the order can only wait.
|
||||
for (const own of state.cities) {
|
||||
const store = state.getCityResourceStock(own);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
this.assertTrue(state.requestBuild(city.id, index));
|
||||
for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour();
|
||||
const entry = state.getTraining(city.id)[0];
|
||||
this.assertEqual(entry.phase, "materials", "it is still gathering");
|
||||
this.assertTrue(entry.stalled, "the entry reports being stalled");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built");
|
||||
}
|
||||
|
||||
test_building_and_unit_share_one_queue_and_are_produced_in_order() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
|
||||
Reference in New Issue
Block a user