- 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. - 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. - A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything. - The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it. - Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice. - The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption.
231 lines
8.8 KiB
JavaScript
231 lines
8.8 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf, seaTile, grantBuilding } from "./framework/helpers.js";
|
|
import {
|
|
STATUS_ENCIRCLED,
|
|
BUILDINGS,
|
|
CIVILISATIONS,
|
|
PROTO_UNITS,
|
|
GOVERNMENTS,
|
|
TECHNOLOGIES,
|
|
TERRAIN_TILES,
|
|
MAP_CONFIG,
|
|
ECONOMY,
|
|
BATTLE,
|
|
effectValue,
|
|
pillageSteps,
|
|
} from "../shared/data.js";
|
|
import { groupDigits } from "../shared/text_format.js";
|
|
|
|
// Regression and edge coverage for the order, economy, combat and catalogue
|
|
// code. These lock in the behavioural guarantees the modular refactor must
|
|
// preserve.
|
|
|
|
function emptyLand(state, skip = 0) {
|
|
let seen = 0;
|
|
for (const cell of state.landCells) {
|
|
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
|
if (seen++ < skip) continue;
|
|
return cell;
|
|
}
|
|
throw new Error("no empty land tile");
|
|
}
|
|
|
|
// A land tile that is empty and has at least one empty land neighbour.
|
|
function boundaryLand(state) {
|
|
for (const cell of state.landCells) {
|
|
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
|
const neighbour = state.topology.neighbours(cell.x, cell.y).find(
|
|
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
|
);
|
|
if (neighbour) return { a: cell, b: neighbour };
|
|
}
|
|
throw new Error("no empty land pair");
|
|
}
|
|
|
|
export class OrderStateRegressionTest extends TestCase {
|
|
test_rejected_move_keeps_the_existing_route() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state)), "the first move is accepted");
|
|
const before = unit.path.map((p) => ({ x: p.x, y: p.y }));
|
|
this.assertFalse(state.requestGroupMove([unit.id], seaTile(state)), "the sea is not a destination");
|
|
this.assertEqual(unit.path, before, "a rejected order leaves the route alone");
|
|
this.assertTrue(state._hasRoute(unit), "the unit keeps marching");
|
|
}
|
|
|
|
test_rejected_scheduled_move_keeps_the_existing_queue() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
const first = emptyLand(state, 0);
|
|
const second = emptyLand(state, 1);
|
|
this.assertTrue(state.requestScheduledMove([unit.id], [first, second]));
|
|
const queued = unit.waypoints.map((p) => ({ x: p.x, y: p.y }));
|
|
this.assertFalse(state.requestScheduledMove([unit.id], [seaTile(state)]));
|
|
this.assertEqual(unit.waypoints, queued, "the scheduled stops survive a rejected order");
|
|
}
|
|
|
|
test_accepted_move_replaces_a_previous_queue() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
state.requestScheduledMove([unit.id], [emptyLand(state, 0), emptyLand(state, 1)]);
|
|
this.assertNotEmpty(unit.waypoints, "a stop is queued");
|
|
this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state, 5)));
|
|
this.assertEmpty(unit.waypoints, "a fresh move drops the old schedule");
|
|
}
|
|
|
|
test_move_to_the_units_own_tile_is_rejected() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
this.assertFalse(
|
|
state.requestGroupMove([unit.id], { x: unit.coords.x, y: unit.coords.y }),
|
|
"staying put is not an order"
|
|
);
|
|
}
|
|
}
|
|
|
|
export class EconomyOrderEdgesTest extends TestCase {
|
|
test_a_free_unit_is_trained_immediately() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
const worker = state.protoUnits.findIndex((p) => p.id === "worker");
|
|
const before = state.units.length;
|
|
this.assertTrue(state.requestTrain(city.id, worker), "a free unit is accepted");
|
|
this.assertGreater(state.units.length, before, "the unit appears at once");
|
|
this.assertNull(state.getTraining(city.id), "nothing is left in the queue");
|
|
}
|
|
|
|
test_buildings_have_no_level_cap() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
state.budgets.set(0, 1e15);
|
|
for (let i = 0; i < 12; i++) state.requestBuild(city.id, 0);
|
|
advanceUntilIdle(state);
|
|
this.assertEqual(state.getCityBuildingLevel(city, 0), 12);
|
|
this.assertTrue(state.requestBuild(city.id, 0), "levels keep rising without a cap");
|
|
}
|
|
|
|
test_demolishing_removes_one_level_without_refunding() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
state.budgets.set(0, 1e15);
|
|
state.requestBuild(city.id, 0);
|
|
advanceUntilIdle(state);
|
|
const budget = state.getBudget(0);
|
|
this.assertTrue(state.requestDemolish(city.id, 0));
|
|
this.assertEqual(state.getCityBuildingLevel(city, 0), 0);
|
|
this.assertApprox(state.getBudget(0), budget, 1e-6, "downgrading refunds nothing");
|
|
}
|
|
|
|
test_cancelling_an_out_of_range_queue_entry_fails() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
grantBuilding(state, city, "barracks");
|
|
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
|
|
state.requestTrain(city.id, infantry);
|
|
this.assertFalse(state.cancelTrain(city.id, -1), "negative index");
|
|
this.assertFalse(state.cancelTrain(city.id, 99), "past the end");
|
|
this.assertTrue(state.cancelTrain(city.id, 0), "the real entry cancels");
|
|
this.assertNull(state.getTraining(city.id), "the emptied queue is dropped");
|
|
}
|
|
|
|
test_tile_gdp_is_population_times_per_capita() {
|
|
const state = smallState();
|
|
const owned = state._territoryByCiv.get(0)[0];
|
|
const expected = state.getTilePopulation(owned) * state.getTileGdpPerCapita(owned);
|
|
this.assertApprox(state.getTileGdp(owned), expected, 1e-6);
|
|
}
|
|
|
|
test_battle_triples_unit_upkeep() {
|
|
const state = smallState();
|
|
const pair = boundaryLand(state);
|
|
const before = state.getCivUpkeepResources(0).steel;
|
|
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
|
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
|
// The two hostile units are only "in battle" once they share a tile.
|
|
state.units[state.units.length - 1].coords = pair.a;
|
|
const after = state.getCivUpkeepResources(0).steel;
|
|
this.assertGreater(after, before, "a unit in battle burns more steel");
|
|
}
|
|
|
|
test_buildings_in_the_catalogue_have_a_cost_curve() {
|
|
for (const proto of BUILDINGS) {
|
|
this.assertTrue(proto.baseCost > 0, `${proto.id} base cost`);
|
|
this.assertTrue(proto.costAlpha > 1, `${proto.id} cost growth`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class AttritionTest extends TestCase {
|
|
test_daily_attrition_destroys_a_unit_at_zero_hit_points() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
unit.statuses = [STATUS_ENCIRCLED];
|
|
unit.hp = 1;
|
|
state._applyDailyStatusEffects();
|
|
this.assertEqual(unit.hp, 0, "the hit points are floored at zero");
|
|
this.assertFalse(state.units.includes(unit), "the unit is removed from the world");
|
|
this.assertNull(state.findUnit(unit.id), "and from the id index");
|
|
}
|
|
|
|
test_a_healthy_encircled_unit_survives_one_day() {
|
|
const state = smallState();
|
|
const unit = state.units.find((u) => u.civ === 0);
|
|
unit.statuses = [STATUS_ENCIRCLED];
|
|
unit.hp = unit.maxHp;
|
|
state._applyDailyStatusEffects();
|
|
this.assertGreater(unit.hp, 0, "attrition is a fraction, not a wipe");
|
|
this.assertTrue(state.units.includes(unit));
|
|
}
|
|
}
|
|
|
|
export class CasualtyRecordingTest extends TestCase {
|
|
test_a_lost_unit_counts_against_an_adjacent_enemy() {
|
|
const state = smallState();
|
|
state._declareWar(0, 1);
|
|
const pair = boundaryLand(state);
|
|
const fallen = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
|
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
|
state._recordMilitaryCasualties([fallen]);
|
|
const conflict = state.conflicts.get("0:1");
|
|
this.assertNotNull(conflict, "the war is on record");
|
|
this.assertEqual(conflict.military.get(0) || 0, 1, "the neighbour's kill is counted");
|
|
}
|
|
}
|
|
|
|
export class CatalogueSplitTest extends TestCase {
|
|
test_the_barrel_reexports_every_slice() {
|
|
this.assertNotEmpty(CIVILISATIONS);
|
|
this.assertNotEmpty(PROTO_UNITS);
|
|
this.assertNotEmpty(BUILDINGS);
|
|
this.assertNotEmpty(GOVERNMENTS);
|
|
this.assertNotEmpty(TECHNOLOGIES);
|
|
this.assertNotEmpty(TERRAIN_TILES);
|
|
this.assertEqual(typeof MAP_CONFIG.mapSize.x, "number");
|
|
this.assertEqual(typeof ECONOMY.budgetGdpRate, "number");
|
|
this.assertEqual(typeof BATTLE.recoveryPerDay, "number");
|
|
}
|
|
|
|
test_pillaging_with_no_units_changes_nothing() {
|
|
const result = pillageSteps(5000, 0);
|
|
this.assertEmpty(result.steps);
|
|
this.assertEqual(result.productionMultiplier, 1);
|
|
}
|
|
|
|
test_effect_value_is_zero_for_a_non_positive_level() {
|
|
this.assertEqual(effectValue({ baseValue: 5, exponent: 1 }, -3), 0);
|
|
}
|
|
}
|
|
|
|
export class TextFormatRegressionTest extends TestCase {
|
|
test_negative_fraction_does_not_render_negative_zero() {
|
|
this.assertEqual(groupDigits(-0.4), "0");
|
|
this.assertEqual(groupDigits(-0.999), "0");
|
|
}
|
|
}
|
|
|
|
// Runs hours until every city's shared production queue has emptied.
|
|
function advanceUntilIdle(state, max = 100000) {
|
|
for (let i = 0; i < max && state.training.size > 0; i++) state.advanceHour();
|
|
}
|
|
|