The catalogue had grown a barracks, wartime shelters, an air-defense battery, a warm-water port and a shipyard, but only as data: infantry still trained without a barracks, shelters did nothing, and an airport was just an airbase. Each building now names the rule it grants with a `mechanic` id, so the simulation and the city panel describe the same thing without magic strings. - Barracks and shipyards open one more production line each. A line sticks to one entry at full rate, so two units build at once rather than the head absorbing every line; the base behaviour (one queue, overflow carrying into the next entry) is unchanged. - Wartime shelters keep 10% of civilians per level safe from bombardment and are very hard to knock down, and ranged fire can now destroy a building level outright, with no refund. Each building rolls its own chance per point of damage, from a generator seeded off the world so a bombardment replays identically. - Air defense fires one volley at a hostile aircraft the moment it enters the city tile, scaled by its level; a plane shot down never drops its bombs. - A port adds 1000 GDP per capita per level, per adjacent sea tile, to every tile in its region. - An airport draws a daily trickle of people from every other airport city in the world, scaled by the destination's level. - Wired the new effects into the city panel and taught the repeatable Industrial Automation to satisfy other technologies' prerequisites. Added tests for each mechanic and for the parallel lines.
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_and_refunds() {
|
|
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.assertGreater(state.getBudget(0), budget, "the refund raises the budget");
|
|
}
|
|
|
|
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.getPlayerUpkeep(0);
|
|
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.getPlayerUpkeep(0);
|
|
this.assertGreater(after, before, "a unit in battle costs more to keep");
|
|
}
|
|
|
|
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.gdpMultiplier, 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();
|
|
}
|
|
|