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.
249 lines
10 KiB
JavaScript
249 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(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"
|
|
);
|
|
}
|
|
}
|