Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve. That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone. Bundles the in-progress chart sizing for the central bank panel.
258 lines
10 KiB
JavaScript
258 lines
10 KiB
JavaScript
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(state.constructionSiteAt(coords), "the upgrade started");
|
|
const job = state.constructionSiteAt(coords);
|
|
this.assertEqual(job.kind, "upgrade");
|
|
this.assertEqual(job.targetLevel, 1);
|
|
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(state.constructionSiteAt(coords), job, "only one upgrade at a time");
|
|
this.assertEqual(state.tileImprovements.get(k), "steel_mill", "the building still stands");
|
|
}
|
|
|
|
test_spending_on_an_upgrade_consumes_cash_and_materials() {
|
|
const state = smallState();
|
|
const { coords, agent } = placeProducer(state, "steel_mill");
|
|
agent.cash = 1e12;
|
|
agent.sales = [10, 12, 15, 18, 22, 27];
|
|
fillMaterialStores(state, 0);
|
|
const before = agent.cash;
|
|
state._tickBuildingDecisions();
|
|
const site = state.constructionSiteAt(coords);
|
|
this.assertNotNull(site, "the agent opened a site");
|
|
this.assertLess(agent.cash, before, "the agent handed its cash to the site");
|
|
this.assertLess(site.budget, before, "the site spent some of it on materials");
|
|
this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over");
|
|
}
|
|
|
|
test_completing_an_upgrade_raises_the_level_and_its_output() {
|
|
const state = smallState();
|
|
const { coords, 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();
|
|
const site = state.constructionSiteAt(coords);
|
|
this.assertNotNull(site);
|
|
site.phase = "construction";
|
|
site.elapsedHours = 0;
|
|
site.totalHours = 1;
|
|
state.advanceHour();
|
|
this.assertEqual(agent.level, 1, "the level arrived");
|
|
this.assertNull(state.constructionSiteAt(coords), "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 { coords, 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");
|
|
this.assertNull(state.constructionSiteAt(coords), "and no site");
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
}
|