Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss. The delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default). Roadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended.
151 lines
6.1 KiB
JavaScript
151 lines
6.1 KiB
JavaScript
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,
|
|
stockpileTarget,
|
|
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);
|
|
// The generated region may open self-sufficient; strip its farmland so the
|
|
// fixture is genuinely short, which is the point of the test.
|
|
for (const coords of state.regionTiles(city)) {
|
|
if (coords.x === city.coords.x && coords.y === city.coords.y) continue;
|
|
state.tilePopulation.set(key(coords.x, coords.y), 0);
|
|
}
|
|
const need = foodNeedPerDay(state.getCityEconomy(city).population);
|
|
const shortfall = need - regionFood(state, city);
|
|
this.assertGreater(shortfall, 0, "the fixture region opens short of food");
|
|
// Hold the reserve full so the test isolates the day's ration: with the
|
|
// pantry a month deep, only the meal the land cannot grow is forced.
|
|
state.getCityResourceStock(city).food = stockpileTarget(need);
|
|
// 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_grows_extra_to_refill_an_empty_reserve() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
// A self-sufficient region with an empty pantry still wants its month back.
|
|
this.assertGreater(regionFood(state, city), 0, "the region has farmland");
|
|
state.getCityResourceStock(city).food = 0;
|
|
// The reserve is dearer to force than the land, but cheap energy against a
|
|
// dear market makes the extra worth growing.
|
|
state.setResourcePrice("energy", 0.00001);
|
|
state.setResourcePrice("food", 1e9);
|
|
state._tickResources();
|
|
const extra = state._foodSynthesis.get(city.id) || 0;
|
|
this.assertGreater(extra, 0, "an empty reserve drives synthesis above the harvest");
|
|
this.assertGreater(
|
|
state.getCityResourceStock(city).food, regionFood(state, city),
|
|
"the store rises toward the reserve instead of hugging the day's harvest"
|
|
);
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
}
|