Files
Battle-for-Tismo/tests/regression_test.js
T
adrien 91dfedf7ec Gave regions their own economies and removed the global market
- 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.
2026-09-22 06:41:58 +02:00

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();
}