- Tile improvements (fortifications, radar towers, coastal cannons), trenches, zone of control, support-unit capture and launch transports, with parabolic barrage arcs drawn between attacker and target. - Focused research that pours points straight into the chosen technology, with per-technology progress kept when the focus changes, plus repeatable levels. - Popularity reworked to integer points, war and pillage effects, minority policies and month-over-month arrows on the nation summary. - City progress bars, square improvement buttons, a technology tab of wide short cards with progress bars, transport cargo readout and double-click to open a city panel.
296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, cityOf } from "./framework/helpers.js";
|
|
import { key } from "../shared/hex.js";
|
|
import { TRENCH } from "../shared/data/combat.js";
|
|
|
|
const BIG_BUDGET = 1.0e15;
|
|
|
|
function emptyLandPair(state) {
|
|
for (const cell of state.landCells) {
|
|
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
|
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
|
if (!state._isLand(neighbour)) continue;
|
|
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
|
|
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ownLand(state, civ = 0) {
|
|
for (const cell of state.landCells) {
|
|
if (state.civAt(cell) !== civ) continue;
|
|
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
|
return cell;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export class TrenchTest extends TestCase {
|
|
test_infantry_dig_in_on_their_own_land() {
|
|
const state = smallState();
|
|
const coords = ownLand(state);
|
|
this.assertNotNull(coords, "the nation holds land");
|
|
const infantry = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
|
this.assertTrue(state.requestDigTrench([infantry.id]));
|
|
this.assertTrue(state.trenches.has(key(coords.x, coords.y)));
|
|
this.assertFalse(state.requestDigTrench([infantry.id]), "already dug in");
|
|
}
|
|
|
|
test_only_infantry_can_dig() {
|
|
const state = smallState();
|
|
const coords = ownLand(state);
|
|
const artillery = state._spawnUnit(coords, 0, state.protoUnits[2]);
|
|
this.assertFalse(state.requestDigTrench([artillery.id]));
|
|
this.assertFalse(state.trenches.has(key(coords.x, coords.y)));
|
|
}
|
|
|
|
test_trenches_multiply_defense_and_cut_gdp() {
|
|
const state = smallState();
|
|
const coords = ownLand(state);
|
|
const k = key(coords.x, coords.y);
|
|
const before = state.getTileGdpPerCapita(coords);
|
|
state.trenches.add(k);
|
|
state._gdpPerCapitaCache.delete(k);
|
|
this.assertApprox(
|
|
state.getTileGdpPerCapita(coords),
|
|
before * TRENCH.gdpMultiplier,
|
|
1e-6,
|
|
"a trench costs the tile a fifth of its GDP per capita"
|
|
);
|
|
// Infantry attackers are blunted partly; artillery far more.
|
|
const infantryProto = state.protoUnits[0];
|
|
const artilleryProto = state.protoUnits[2];
|
|
this.assertApprox(
|
|
state._tileFortificationMultiplier(coords, infantryProto),
|
|
TRENCH.meleeDefenseMultiplier,
|
|
1e-9
|
|
);
|
|
this.assertApprox(
|
|
state._tileFortificationMultiplier(coords, artilleryProto),
|
|
TRENCH.artilleryDefenseMultiplier,
|
|
1e-9
|
|
);
|
|
}
|
|
}
|
|
|
|
export class CaptureTest extends TestCase {
|
|
test_infantry_captures_unaccompanied_artillery() {
|
|
const state = smallState();
|
|
const pair = emptyLandPair(state);
|
|
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
|
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
|
this.assertTrue(state.requestMove(infantry.id, pair.a));
|
|
state.advanceMovement(1000);
|
|
this.assertEqual(artillery.civ, 1, "the battery changed hands");
|
|
}
|
|
|
|
test_infantry_beside_the_artillery_protects_it() {
|
|
const state = smallState();
|
|
const pair = emptyLandPair(state);
|
|
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
|
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
|
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
|
this.assertTrue(state.requestMove(infantry.id, pair.a));
|
|
state.advanceMovement(1000);
|
|
this.assertEqual(artillery.civ, 0, "the artillery is not captured while sheltered");
|
|
}
|
|
|
|
test_walking_onto_a_radar_tower_captures_it() {
|
|
const state = smallState();
|
|
const pair = emptyLandPair(state);
|
|
const k = key(pair.a.x, pair.a.y);
|
|
state.tileImprovements.set(k, "radar_tower");
|
|
state.tileImprovementOwner.set(k, 0);
|
|
state.tileImprovementHp.set(k, 500);
|
|
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
|
this.assertTrue(state.requestMove(infantry.id, pair.a));
|
|
state.advanceMovement(1000);
|
|
this.assertEqual(state.tileImprovementOwner.get(k), 1, "the radar tower changed hands");
|
|
this.assertTrue(state.tileImprovements.has(k), "the tower is captured, not destroyed");
|
|
}
|
|
|
|
test_capturing_a_city_takes_its_aircraft() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 1);
|
|
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
|
|
city.buildings[airport] = 1;
|
|
const jet = state._spawnUnit(
|
|
city.coords,
|
|
1,
|
|
state.protoUnits.find((u) => u.id === "jet_fighter"),
|
|
city.id
|
|
);
|
|
state._captureCity(city, 0);
|
|
this.assertEqual(jet.civ, 0, "the aircraft is captured with the city");
|
|
this.assertEqual(jet.homeCityId, city.id);
|
|
}
|
|
}
|
|
|
|
export class TileImprovementTest extends TestCase {
|
|
test_a_radar_tower_takes_time_to_build() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const coords = ownLand(state);
|
|
const k = key(coords.x, coords.y);
|
|
this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
|
|
this.assertFalse(state.tileImprovements.has(k), "not raised instantly");
|
|
for (let i = 0; i < 100000 && !state.tileImprovements.has(k); i++) state.advanceHour();
|
|
this.assertEqual(state.tileImprovements.get(k), "radar_tower", "the work completes");
|
|
this.assertEqual(state.tileImprovementOwner.get(k), 0);
|
|
}
|
|
|
|
test_a_coastal_cannon_needs_the_sea() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const inland = ownLand(state);
|
|
this.assertFalse(
|
|
state.requestBuildTileImprovement(0, inland, "coastal_cannon"),
|
|
"an inland tile cannot host a coastal cannon"
|
|
);
|
|
}
|
|
|
|
test_improvements_cost_an_hourly_maintenance() {
|
|
const state = smallState();
|
|
state.budgets.set(0, BIG_BUDGET);
|
|
const coords = ownLand(state);
|
|
const k = key(coords.x, coords.y);
|
|
const before = state.getTileImprovementUpkeep(0);
|
|
state.tileImprovements.set(k, "fortifications");
|
|
state.tileImprovementOwner.set(k, 0);
|
|
state.tileImprovementHp.set(k, 2000);
|
|
this.assertGreater(state.getTileImprovementUpkeep(0), before, "forts carry upkeep");
|
|
}
|
|
|
|
test_fortifications_hold_permanent_control() {
|
|
const state = smallState();
|
|
const city = cityOf(state, 0);
|
|
// A tile right beside the city: without the fort it would fall with it.
|
|
const coords = state.topology
|
|
.neighbours(city.coords.x, city.coords.y)
|
|
.find((n) => state._isLand(n) && state.civAt(n) === 0 && !state.unitAt(n) && !state.cityAt(n));
|
|
this.assertNotNull(coords, "the city borders a tile of its own land");
|
|
const k = key(coords.x, coords.y);
|
|
state.tileImprovements.set(k, "fortifications");
|
|
state.tileImprovementOwner.set(k, 0);
|
|
state.tileImprovementHp.set(k, 2000);
|
|
state._captureCity(city, 1);
|
|
this.assertEqual(state.civAt(coords), 0, "the fort's area of control never changes hands");
|
|
}
|
|
|
|
test_a_coastal_cannon_fires_on_a_warship() {
|
|
const state = smallState();
|
|
const coastal = state.landCells.find((cell) =>
|
|
state.civAt(cell) === 0 &&
|
|
state.topology.neighbours(cell.x, cell.y).some(
|
|
(n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
|
|
)
|
|
);
|
|
this.assertNotNull(coastal, "the nation holds a coastal tile");
|
|
const sea = state.topology.neighbours(coastal.x, coastal.y).find(
|
|
(n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
|
|
);
|
|
const k = key(coastal.x, coastal.y);
|
|
state.tileImprovements.set(k, "coastal_cannon");
|
|
state.tileImprovementOwner.set(k, 0);
|
|
state.tileImprovementHp.set(k, 800);
|
|
const warship = state._spawnUnit(
|
|
sea,
|
|
1,
|
|
state.protoUnits.find((u) => u.id === "battleship")
|
|
);
|
|
const hp = warship.hp;
|
|
state._tickCoastalCannons();
|
|
this.assertLess(warship.hp, hp, "the cannon hits the warship");
|
|
this.assertTrue(state.tileImprovements.has(k), "the cannon survives (only fires)");
|
|
}
|
|
}
|
|
|
|
export class TransportTest extends TestCase {
|
|
coastalSetup(state) {
|
|
for (const cell of state.landCells) {
|
|
if (state.civAt(cell) !== 0) continue;
|
|
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
|
if (state.tiles[key(neighbour.x, neighbour.y)].terrainClass !== "Sea") continue;
|
|
if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
|
|
return { land: { x: cell.x, y: cell.y }, sea: { x: neighbour.x, y: neighbour.y } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test_infantry_board_a_launch_and_are_carried() {
|
|
const state = smallState();
|
|
const spot = this.coastalSetup(state);
|
|
this.assertNotNull(spot, "the nation has a coastal tile");
|
|
const launchProto = state.protoUnits.find((u) => u.id === "launch");
|
|
const launch = state._spawnUnit(spot.sea, 0, launchProto);
|
|
const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
|
|
this.assertTrue(state.requestEmbark([infantry.id], launch.id), "boarding begins");
|
|
for (let i = 0; i < 24; i++) state.advanceHour();
|
|
this.assertNull(state.findUnit(infantry.id), "the infantry left the map");
|
|
this.assertEqual(launch.cargo.length, 1, "the infantry is cargo");
|
|
}
|
|
|
|
test_a_loaded_launch_disembarks_its_stack() {
|
|
const state = smallState();
|
|
const spot = this.coastalSetup(state);
|
|
const launchProto = state.protoUnits.find((u) => u.id === "launch");
|
|
const launch = state._spawnUnit(spot.sea, 0, launchProto);
|
|
const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
|
|
state.requestEmbark([infantry.id], launch.id);
|
|
for (let i = 0; i < 24; i++) state.advanceHour();
|
|
this.assertTrue(state.requestDisembark([launch.id], spot.land), "the troops go ashore");
|
|
this.assertEqual(launch.cargo.length, 0, "the launch is empty");
|
|
this.assertGreater(state.unitsAt(spot.land).length, 0, "a unit stands on the shore");
|
|
}
|
|
}
|
|
|
|
export class ZoneOfControlTest extends TestCase {
|
|
// Two neighbouring, unclaimed land tiles, so terrain and ownership never
|
|
// interfere with the zone-of-control check under test.
|
|
neutralPair(state) {
|
|
for (const cell of state.landCells) {
|
|
if (state.civAt(cell) !== 0) continue;
|
|
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
|
if (!state._isLand(neighbour) || state.civAt(neighbour) !== 0) continue;
|
|
if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
|
|
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test_a_unit_cannot_cross_between_two_denied_tiles() {
|
|
const state = smallState();
|
|
const pair = this.neutralPair(state);
|
|
this.assertNotNull(pair, "the map has two adjacent neutral land tiles");
|
|
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
|
// The hostile is only denied by the artificial zone, not by occupancy.
|
|
const zoc = new Set([key(pair.a.x, pair.a.y), key(pair.b.x, pair.b.y)]);
|
|
this.assertFalse(
|
|
state._canUnitEnter(mover, pair.b, false, -1, null, zoc, pair.a),
|
|
"denied to denied is blocked"
|
|
);
|
|
this.assertTrue(
|
|
state._canUnitEnter(mover, pair.b, false, -1, null, new Set([key(pair.b.x, pair.b.y)]), pair.a),
|
|
"entering the ring from outside is allowed"
|
|
);
|
|
this.assertTrue(
|
|
state._canUnitEnter(mover, pair.b, true, -1, null, zoc, pair.a),
|
|
"a deliberate attack on the goal is allowed"
|
|
);
|
|
}
|
|
|
|
test_a_hostile_unit_denies_its_tile_and_neighbours() {
|
|
const state = smallState();
|
|
const pair = emptyLandPair(state);
|
|
state._spawnUnit(pair.a, 1, state.protoUnits[0]);
|
|
const zoc = state._zoneOfControl(0);
|
|
this.assertTrue(zoc.has(key(pair.a.x, pair.a.y)), "the hostile's tile is denied");
|
|
for (const neighbour of state.topology.neighbours(pair.a.x, pair.a.y)) {
|
|
this.assertTrue(zoc.has(key(neighbour.x, neighbour.y)), "each neighbour is denied");
|
|
}
|
|
}
|
|
}
|