City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar. Buildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes.
2109 lines
82 KiB
JavaScript
2109 lines
82 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||
import { GameScreen } from "../client/js/game_screen.js";
|
||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||
import { hoursInNextMonth } from "../shared/game_clock.js";
|
||
import { compact } from "../shared/text_format.js";
|
||
import { tileImprovementResourceCost } from "../shared/resources.js";
|
||
import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
|
||
import { parseKey } from "../shared/hex.js";
|
||
import { smallState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
|
||
|
||
function stubNetwork() {
|
||
return {
|
||
isAdmin: false,
|
||
localPlayerId: 2,
|
||
players: { 2: { id: 2, name: "Carol", admin: false } },
|
||
orders: [],
|
||
sendOrder(order) {
|
||
this.orders.push(order);
|
||
},
|
||
};
|
||
}
|
||
|
||
function makeConfig() {
|
||
return {
|
||
playerName: "Carol",
|
||
playerCivId: "france",
|
||
civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]],
|
||
};
|
||
}
|
||
|
||
function landAtDistance(state, center, distance) {
|
||
for (const k in state.tiles) {
|
||
const parts = k.split(",").map(Number);
|
||
const coords = { x: parts[0], y: parts[1] };
|
||
if (state.tiles[k].terrainClass !== "Land") continue;
|
||
if (state.cityAt(coords) || state.unitAt(coords)) continue;
|
||
if (state.topology.tileDistance(center, coords) === distance) return coords;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export class GameScreenTest extends TestCase {
|
||
async test_versioned_collections_are_reused_when_unchanged() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const population = screen._population;
|
||
const territory = screen.map.territory;
|
||
const regions = screen.map.regions;
|
||
const ethnicity = screen._tileEthnicity;
|
||
const baseline = screen._productionBaseline;
|
||
// A second snapshot with no state change carries the same versions, so
|
||
// none of the big per-tile maps should be rebuilt.
|
||
screen.onState(state.snapshot(0));
|
||
this.assert(population === screen._population, "population map is reused");
|
||
this.assert(baseline === screen._productionBaseline, "the growth baseline is reused");
|
||
this.assert(territory === screen.map.territory, "territory map is reused");
|
||
this.assert(regions === screen.map.regions, "region map is reused");
|
||
this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused");
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_renders_snapshot_and_sends_orders() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||
|
||
const state = smallState();
|
||
stripPorts(state, 0);
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
screen.onState(state.snapshot(0));
|
||
this.assertEqual(env.$("#civ-name").text(), state.civilisations[0].name);
|
||
this.assertEqual(env.$("#player-name").text(), "Carol");
|
||
this.assertTrue(env.$("#stat-pop").text() !== "0");
|
||
this.assertTrue(env.$("#stat-date").text().includes("2000"));
|
||
|
||
screen.map.onCitySelected(capital.id);
|
||
this.assertFalse(env.$("#city-info").hasClass("hidden"));
|
||
this.assertEqual(env.$("#city-name").text(), capital.name);
|
||
|
||
env.$("#city-manage").click();
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||
this.assertSize(env.$("#city-train-list button"), 3, "infantry, artillery and radar can be trained");
|
||
env.$("#city-train-list button").first().click();
|
||
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
|
||
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const goal = adjacentLand(state, unit);
|
||
await screen.map.onMoveOrdered([unit.id], goal);
|
||
this.assertEqual(network.orders[1], { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
|
||
this.assertTrue(screen.map._unitMotion.get(unit.id).predicting, "the move was predicted");
|
||
|
||
screen.leave();
|
||
this.assertFalse(env.$("#screen-game").hasClass("active"));
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_nation_panel_opens() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
|
||
this.assertSize(env.$("#government-list .row-card"), 3);
|
||
this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_top_panel_shows_approval_with_colour() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
this.assertEqual(env.$("#stat-approval").text(), "50%");
|
||
this.assertTrue(env.$("#stat-approval").hasClass("approval-neutral"));
|
||
|
||
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
||
state._shiftGovOpinion(eth, 0, -0.35);
|
||
}
|
||
screen.onState(state.snapshot(0));
|
||
this.assertTrue(
|
||
env.$("#stat-approval").hasClass("approval-negative"),
|
||
`disapproval turns red (${env.$("#stat-approval").text()})`
|
||
);
|
||
|
||
for (let eth = 0; eth < state.civilisations.length; eth++) {
|
||
state._shiftGovOpinion(eth, 0, 0.7);
|
||
}
|
||
screen.onState(state.snapshot(0));
|
||
this.assertTrue(
|
||
env.$("#stat-approval").hasClass("approval-positive"),
|
||
`approval turns green (${env.$("#stat-approval").text()})`
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_top_panel_arrows_compare_to_last_month() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
// January 2000 carries no baseline, so no change markers.
|
||
this.assertSize(env.$("#stat-pop .stat-arrow"), 0, "no arrow without a past month");
|
||
|
||
const snapshot = state.snapshot(0);
|
||
const stats = snapshot.viewerStats;
|
||
snapshot.monthly = [{
|
||
population: stats.population / 2,
|
||
gdp: stats.gdp / 2,
|
||
gdpPerCapita: stats.gdpPerCapita / 2,
|
||
popularity: stats.popularity,
|
||
approval: stats.approval,
|
||
budget: stats.budget,
|
||
}];
|
||
screen.onState(snapshot);
|
||
this.assertEqual(
|
||
env.$("#stat-pop .stat-arrow").text(),
|
||
"▲▲",
|
||
"a doubling population shows two green up arrows"
|
||
);
|
||
this.assertTrue(env.$("#stat-pop .stat-arrow").hasClass("up"));
|
||
this.assertEqual(
|
||
env.$("#stat-approval .stat-arrow").text(),
|
||
"=",
|
||
"an unchanged popularity shows an equals sign"
|
||
);
|
||
this.assertTrue(env.$("#stat-approval .stat-arrow").hasClass("equal"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_buildings_and_nation_modals_refresh_on_snapshot() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||
this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length);
|
||
screen.onState(state.snapshot(0));
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||
|
||
env.$("#civ-flag").click();
|
||
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
|
||
screen.onState(state.snapshot(0));
|
||
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_city_budget_tab_shows_the_region_budget() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
env.$("#modal-city .tab[data-tab=budget]").click();
|
||
this.assertTrue(env.$("#city-tab-budget").hasClass("active"), "the budget tab opens");
|
||
const cityStats = state.snapshot(0).cityStats.find((c) => c.id === capital.id);
|
||
this.assertNotNull(cityStats.budget, "the city stats carry a region budget");
|
||
const text = env.$("#city-budget-list").text();
|
||
this.assertTrue(text.includes("Trade taxes"), "region tax is listed");
|
||
this.assertTrue(text.includes("TOTAL"), "the region net is totalled");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_training_modal_tracks_progress() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
this.assertTrue(state.requestTrain(capital.id, 0));
|
||
screen.onState(state.snapshot(0));
|
||
this.assertFalse(env.$("#city-train-status").hasClass("hidden"));
|
||
this.assertTrue(env.$("#city-train-label").text().includes("Training"));
|
||
|
||
state.training.get(capital.id)[0].elapsedHours = 3;
|
||
screen.onState(state.snapshot(0));
|
||
this.assertTrue(env.$("#city-train-label").text().includes("left"));
|
||
|
||
state.training.delete(capital.id);
|
||
screen.onState(state.snapshot(0));
|
||
this.assertEqual(env.$("#city-train-label").text(), "Training complete");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_attack_and_scheduled_move_orders_are_serialised() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const raw = state.units.find((u) => u.civ === 0);
|
||
const unit = screen._unit(raw.id);
|
||
const goal = adjacentLand(state, raw);
|
||
await screen._sendMove([unit], goal, null, "attack");
|
||
this.assertEqual(network.orders[0], {
|
||
type: "attack",
|
||
unit: unit.id,
|
||
coords: [goal.x, goal.y],
|
||
});
|
||
await screen._sendMove([unit], goal, null, "schedule");
|
||
this.assertEqual(network.orders[1], {
|
||
type: "schedule",
|
||
unit: unit.id,
|
||
coords: [goal.x, goal.y],
|
||
goals: [[goal.x, goal.y]],
|
||
append: true,
|
||
});
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_ranged_attack_is_sent_without_predicting_a_march() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const artillery = state._spawnUnit(
|
||
home.coords,
|
||
0,
|
||
state.protoUnits.find((u) => u.id === "artillery")
|
||
);
|
||
const goal = adjacentLand(state, artillery);
|
||
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
const unit = screen._unit(artillery.id);
|
||
this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible");
|
||
await screen._sendMove([unit], goal, null, "attack");
|
||
this.assertEqual(network.orders[0], {
|
||
type: "attack",
|
||
unit: unit.id,
|
||
coords: [goal.x, goal.y],
|
||
});
|
||
this.assertFalse(
|
||
screen.map._unitMotion.get(unit.id).predicting,
|
||
"the icon does not march toward a target hit from range"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_order_is_sent_and_the_flight_is_predicted() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||
const goal = adjacentLand(state, home);
|
||
const defender = state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
const unit = screen._unit(jet.id);
|
||
this.assertNotNull(screen._unit(defender.id), "the enemy stack is visible");
|
||
await screen._sendMove([unit], goal, null, "attack");
|
||
this.assertEqual(network.orders[0], {
|
||
type: "attack",
|
||
unit: unit.id,
|
||
coords: [goal.x, goal.y],
|
||
});
|
||
this.assertTrue(
|
||
screen.map._unitMotion.get(unit.id).predicting,
|
||
"the aircraft flies toward the target"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_rearming_aircraft_is_refused_with_a_message() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||
jet.strikeReadyHour = 100;
|
||
const goal = adjacentLand(state, home);
|
||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
|
||
this.assertEqual(network.orders.length, 0, "no order is sent while re-arming");
|
||
this.assertEqual(env.$("#confirm-title").text(), "Aircraft re-arming");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_unit_panel_shows_an_aircraft_is_rearming() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||
jet.strikeReadyHour = state.totalHours + 10;
|
||
screen.onState(state.snapshot(0));
|
||
screen._selectUnits([jet.id]);
|
||
this.assertTrue(
|
||
env.$("#unit-name").text().includes("re-arming"),
|
||
"the panel shows the re-arming cooldown"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_without_munitions_is_refused_with_a_message() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||
const goal = adjacentLand(state, home);
|
||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||
const snapshot = state.snapshot(0);
|
||
// The server charges an air strike in munitions, not money: without the
|
||
// steel and high-tech in store the browser must refuse it.
|
||
snapshot.viewerStats.resources.stock.steel = 0;
|
||
snapshot.viewerStats.resources.stock.hightech = 0;
|
||
screen.onState(snapshot);
|
||
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
|
||
this.assertEqual(network.orders.length, 0, "no order is sent without the munitions");
|
||
this.assertEqual(env.$("#confirm-title").text(), "Insufficient munitions");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_with_munitions_is_allowed() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const home = state.units.find((u) => u.civ === 0);
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const jet = state._spawnUnit(home.coords, 0, jetProto);
|
||
screen.onState(state.snapshot(0));
|
||
this.assertNull(
|
||
screen._airStrikeBlocked([screen._unit(jet.id)]),
|
||
"full stores arm the aircraft"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_beyond_the_round_trip_is_refused() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const city = state.cities.find((c) => c.civ === 0);
|
||
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
|
||
city.buildings[airport] = 1;
|
||
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
|
||
const saved = jetProto.enduranceHours;
|
||
// Two hours of fuel is a ten-tile round trip, so a six-tile strike cannot
|
||
// get home even though it sits well inside the mission radius.
|
||
jetProto.enduranceHours = 2;
|
||
try {
|
||
const jet = state._spawnUnit(city.coords, 0, jetProto, city.id);
|
||
const goal = landAtDistance(state, city.coords, 6);
|
||
this.assertNotNull(goal, "the fixture has a tile six tiles away");
|
||
screen.onState(state.snapshot(0));
|
||
const unit = screen._unit(jet.id);
|
||
const reason = screen._airStrikeRangeError([unit], goal);
|
||
this.assertTrue(
|
||
typeof reason === "string" && reason.length > 0,
|
||
"the round trip is recognised as out of reach"
|
||
);
|
||
this.assertTrue(reason.includes("6 tiles away"), "the message names the target distance");
|
||
this.assertTrue(
|
||
reason.includes("effective range is 5 tiles"),
|
||
"the message names the plane's effective range"
|
||
);
|
||
const near = landAtDistance(state, city.coords, 4);
|
||
this.assertNotNull(near, "the fixture has a tile four tiles away");
|
||
this.assertNull(screen._airStrikeRangeError([unit], near), "a nearer target is in reach");
|
||
} finally {
|
||
jetProto.enduranceHours = saved;
|
||
}
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_route_reaches_a_garrisoned_city() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const city = state.cities.find((c) => c.civ === 0);
|
||
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
|
||
city.buildings[airport] = 1;
|
||
const bomberProto = state.protoUnits.find((u) => u.id === "bomber");
|
||
const bomber = state._spawnUnit(city.coords, 0, bomberProto, city.id);
|
||
const enemyCity = state.cities.find((c) => c.civ === 1);
|
||
screen.onState(state.snapshot(0));
|
||
const unit = screen._unit(bomber.id);
|
||
const route = await screen.map.planMove(unit, {
|
||
x: enemyCity.coords.x,
|
||
y: enemyCity.coords.y,
|
||
});
|
||
this.assertNotNull(route, "the client mirrors the server into the city tile");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_finishing_an_airport_unlocks_aircraft_in_an_open_panel() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
stripPorts(state, 0);
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
const before = env.$("#city-train-list button").length;
|
||
this.assertEqual(before, 3, "only infantry, artillery and radar are trainable at first");
|
||
|
||
// The airport is completed server-side and a fresh snapshot arrives while
|
||
// the panel is still open.
|
||
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
|
||
capital.buildings[airport] = 1;
|
||
screen.onState(state.snapshot(0));
|
||
this.assertGreater(
|
||
env.$("#city-train-list button").length,
|
||
before,
|
||
"the aircraft appears in the still-open panel"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_testing_mode_city_panel_offers_free_instant_orders() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.testing = true;
|
||
stripPorts(state, 0);
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
|
||
this.assertSize(env.$("#city-train-list .free-button"), 3, "a free button per trainable unit");
|
||
env.$("#city-train-list .free-button").first().click();
|
||
this.assertEqual(network.orders[0], {
|
||
type: "train",
|
||
city: capital.id,
|
||
proto: 0,
|
||
free: true,
|
||
});
|
||
env.$("#modal-city .tab[data-tab='buildings']").click();
|
||
this.assertGreater(
|
||
env.$("#city-buildings-list .free-button").length,
|
||
0,
|
||
"a free button per building"
|
||
);
|
||
env.$("#city-buildings-list .free-button").first().click();
|
||
this.assertTrue(network.orders[1].free, "the building order is free");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_training_modal_lists_the_queue_and_cancels() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
state.requestTrain(capital.id, 0);
|
||
state.requestTrain(capital.id, 0);
|
||
screen.onState(state.snapshot(0));
|
||
this.assertSize(env.$("#city-train-queue .queue-row"), 1, "contiguous identical units merge");
|
||
this.assertTrue(
|
||
env.$("#city-train-queue .queue-row").first().text().includes("×2"),
|
||
"the merged row shows how many units it holds"
|
||
);
|
||
this.assertTrue(
|
||
env.$("#city-train-queue .queue-row").first().text().includes("all ready in"),
|
||
"the merged row forecasts the whole run"
|
||
);
|
||
env.$("#city-train-queue .queue-row").first().find("button").click();
|
||
this.assertEqual(network.orders[0], { type: "cancel_train", city: capital.id, index: 1 });
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_bottom_panel_information() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
screen.map.onTileRequested(capital.coords);
|
||
this.assertFalse(env.$("#tile-info").hasClass("hidden"));
|
||
this.assertTrue(env.$("#tile-coords").text().includes("Tile"));
|
||
// The panel leads with what the tile produces, and GDP is only shown for
|
||
// its region.
|
||
this.assertTrue(
|
||
env.$("#tile-gdp").text().includes("Region GDP/capita"),
|
||
"GDP is shown at the region level"
|
||
);
|
||
this.assertGreater(
|
||
env.$("#tile-production .tile-prod-row").length,
|
||
0,
|
||
"the tile panel lists the resources it produces"
|
||
);
|
||
this.assertTrue(
|
||
env.$("#tile-production").text().includes("Food"),
|
||
"the capital's people grow food"
|
||
);
|
||
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
screen.map.onUnitSelected(unit.id);
|
||
this.assertFalse(env.$("#unit-info").hasClass("hidden"));
|
||
this.assertTrue(env.$("#unit-name").text().length > 0);
|
||
this.assertEqual(
|
||
env.$("#unit-health-label").text(),
|
||
`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`,
|
||
"the panel shows hit points next to the bar"
|
||
);
|
||
screen._showUnit(null);
|
||
this.assertTrue(env.$("#unit-info").hasClass("hidden"));
|
||
screen._showCity(null);
|
||
this.assertTrue(env.$("#city-info").hasClass("hidden"));
|
||
this.assertNull(screen._unit(999));
|
||
this.assertNull(screen._city(999));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_tile_and_unit_panels_dock_independently() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
|
||
// Inspecting a tile opens the bottom-right tile panel on its own.
|
||
screen.map.onTileRequested(capital.coords);
|
||
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens");
|
||
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the unit panel stays shut");
|
||
|
||
// Selecting a unit opens the bottom-left unit panel and leaves the tile
|
||
// panel showing the inspected tile.
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
screen.map.onUnitSelected(unit.id);
|
||
this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens");
|
||
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel stays open");
|
||
|
||
// Clearing the selection (an empty left-click) dismisses both.
|
||
screen.map.clearSelection();
|
||
this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "the click clears the tile panel");
|
||
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "the click clears the unit panel");
|
||
|
||
// ESC over the map does the same.
|
||
screen.map.onTileRequested(capital.coords);
|
||
screen.map.onUnitSelected(unit.id);
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "Escape" }));
|
||
this.assertTrue(env.$("#tile-panel").hasClass("hidden"), "ESC clears the tile panel");
|
||
this.assertTrue(env.$("#unit-panel").hasClass("hidden"), "ESC clears the unit panel");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_keyboard_and_render_loop() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
|
||
const before = screen.map.camera.x;
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "d" }));
|
||
this.assertApprox(screen.map.camera.x, before - 40, 1e-9);
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "a", shiftKey: true }));
|
||
this.assertApprox(screen.map.camera.x, before - 40 + 120, 1e-9);
|
||
const zoom = screen.map.camera.zoom;
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "+" }));
|
||
this.assertGreater(screen.map.camera.zoom, zoom);
|
||
const zoomedIn = screen.map.camera.zoom;
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "-" }));
|
||
this.assertLess(screen.map.camera.zoom, zoomedIn);
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "z" }));
|
||
env.$(env.window).trigger(env.$.Event("keydown", { key: "Escape" }));
|
||
|
||
screen._lastFrame = 0;
|
||
screen._frame(1000);
|
||
screen._frame(2000);
|
||
screen._frame(3000);
|
||
this.assertTrue(env.$("#fps-label").text().includes("FPS"));
|
||
|
||
screen.leave();
|
||
screen._frame(4000);
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_snapshot_refreshes_researched_and_buildings() { const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
|
||
state.requestFocusTechnology(0, method);
|
||
state._addResearch(0, state.getTechnologyCost(0, method));
|
||
screen.onState(state.snapshot(0));
|
||
this.assertTrue(screen._researched.has(method), "researched technologies are mirrored");
|
||
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
state.requestBuild(capital.id, 0);
|
||
for (let i = 0; i < 1000 && state.training.size > 0; i++) state.advanceHour();
|
||
screen.onState(state.snapshot(0));
|
||
this.assertEqual(screen._cityBuildings.get(capital.id)["0"], 1, "building levels are mirrored");
|
||
|
||
env.$("#civ-flag").click();
|
||
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_unit_panel_shows_terrain_defense_and_tooltip() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const mountain = state.landCells.find(
|
||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain"
|
||
);
|
||
this.assertNotNull(mountain, "the map has a mountain");
|
||
const unit = state._spawnUnit(mountain, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
const data = state.snapshot(0).units.find((u) => u.id === unit.id);
|
||
screen._showUnit(data);
|
||
|
||
const proto = screen.protoUnits[unit.proto];
|
||
this.assertEqual(env.$("#unit-attack").text(), `Attack ${proto.attack}`);
|
||
this.assertEqual(env.$("#unit-attack").attr("data-tooltip"), `Base ${proto.attack}`);
|
||
this.assertEqual(env.$("#unit-defense").text(), `Defense ${proto.defense * 2}`);
|
||
this.assertEqual(
|
||
env.$("#unit-defense").attr("data-tooltip"),
|
||
`Base ${proto.defense}, Mountain +${proto.defense}`
|
||
);
|
||
|
||
env.$("#unit-defense").trigger("mouseenter");
|
||
this.assertFalse(env.$("#stat-tooltip").hasClass("hidden"));
|
||
this.assertEqual(
|
||
env.$("#stat-tooltip").text(),
|
||
`Base ${proto.defense}, Mountain +${proto.defense}`
|
||
);
|
||
env.$("#unit-defense").trigger("mouseleave");
|
||
this.assertTrue(env.$("#stat-tooltip").hasClass("hidden"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_unit_panel_shows_city_defense_bonus() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const plain = state.landCells.find(
|
||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
|
||
);
|
||
this.assertNotNull(plain, "found a plain tile");
|
||
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
|
||
state._rebuildCityIndex();
|
||
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
screen._showUnit(state.snapshot(0).units.find((u) => u.id === unit.id));
|
||
|
||
const proto = screen.protoUnits[unit.proto];
|
||
this.assertEqual(env.$("#unit-defense").text(), `Defense ${Math.round(proto.defense * 1.5)}`);
|
||
this.assertEqual(
|
||
env.$("#unit-defense").attr("data-tooltip"),
|
||
`Base ${proto.defense}, City +${Math.round(proto.defense * 0.5)}`
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_the_city_button_opens_a_city_when_its_unit_is_selected() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const plain = state.landCells.find(
|
||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
|
||
);
|
||
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
|
||
state._rebuildCityIndex();
|
||
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
screen.map.onUnitSelected(unit.id);
|
||
this.assertFalse(env.$("#city-info").hasClass("hidden"), "the city panel is shown");
|
||
this.assertFalse(env.$("#city-manage").hasClass("hidden"), "the city button is available");
|
||
env.$("#city-manage").click();
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the button opens the city modal");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_double_clicking_a_unit_in_a_city_opens_the_city() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const plain = state.landCells.find(
|
||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
|
||
);
|
||
state._spawnCity({ civIndex: 0, coords: { x: plain.x, y: plain.y }, isCapital: false });
|
||
state._rebuildCityIndex();
|
||
const unit = state._spawnUnit(plain, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
const view = screen.map._unitViews.get(unit.id);
|
||
this.assertNotNull(view, "the unit is drawn");
|
||
view.dispatchEvent(new env.window.MouseEvent("dblclick", { bubbles: true }));
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city opens");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_the_stat_tooltip_sits_outside_the_hud() {
|
||
const env = await setupDom();
|
||
try {
|
||
this.assertSize(env.$("#hud #stat-tooltip"), 0, "the tooltip is not trapped in the HUD");
|
||
this.assertEqual(env.$("#screen-game > #stat-tooltip").length, 1,
|
||
"it is a sibling of the HUD, so it can paint above the unit panel");
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_the_client_picks_the_nearest_return_airport() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const cities = state.cities.filter((c) => c.civ === 0);
|
||
for (const city of cities) grantBuilding(state, city, "airport");
|
||
const [home, away] = cities;
|
||
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
|
||
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
const data = screen.snapshot.units.find((u) => u.id === unit.id);
|
||
const beside = state.topology
|
||
.neighbours(away.coords.x, away.coords.y)
|
||
.filter((n) => state._isLand(n))
|
||
.sort((a, b) =>
|
||
state.topology.tileDistance(home.coords, b) - state.topology.tileDistance(home.coords, a)
|
||
)[0];
|
||
this.assertNotNull(beside, "a land tile beyond the far city");
|
||
const airport = screen._airReturnAirport(data, beside);
|
||
this.assertNotNull(airport, "there is a return airport");
|
||
this.assertEqual(airport.id, away.id, "the nearest airport, not the home base");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_a_local_config_offers_the_free_buttons() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), { ...makeConfig(), testing: true });
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
this.assertTrue(screen.testing, "a local page turns on the testing buttons");
|
||
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
this.assertGreater(env.$("#modal-city .free-button").length, 0, "the city offers free orders");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_unit_panel_shows_transport_cargo_and_boarding() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const launchProto = state.protoUnits.find((u) => u.id === "launch");
|
||
const infantryProto = state.protoUnits.find((u) => u.id === "modern_infantry");
|
||
const seaKey = Object.keys(state.tiles).find(
|
||
(k) => state.tiles[k].terrainClass === "Sea"
|
||
);
|
||
this.assertNotNull(seaKey, "the map has a sea tile");
|
||
const spot = parseKey(seaKey);
|
||
const launch = state._spawnUnit(spot, 0, launchProto);
|
||
launch.cargo = [{ proto: state.protoUnits.indexOf(infantryProto), hp: infantryProto.maxHp }];
|
||
launch.embarking = { ids: [99], hoursLeft: 6 };
|
||
const snapshot = state.snapshot(0);
|
||
screen.onState(snapshot);
|
||
screen._showUnit(snapshot.units.find((u) => u.id === launch.id));
|
||
|
||
const cargo = env.$("#unit-cargo");
|
||
this.assertFalse(cargo.hasClass("hidden"));
|
||
this.assertTrue(cargo.text().includes("Cargo 1/1"), "the berths are shown");
|
||
this.assertTrue(cargo.text().includes(infantryProto.name), "the passenger is named");
|
||
this.assertTrue(cargo.text().includes("boarding 1"), "a boarding party is shown");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_unit_panel_hides_cargo_for_a_plain_unit() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const spot = state.landCells.find(
|
||
(c) => state.tiles[`${c.x},${c.y}`].terrainClass === "Land" && !state.cityAt(c)
|
||
);
|
||
const unit = state._spawnUnit(spot, 0, state.protoUnits[0]);
|
||
const snapshot = state.snapshot(0);
|
||
screen.onState(snapshot);
|
||
screen._showUnit(snapshot.units.find((u) => u.id === unit.id));
|
||
this.assertTrue(env.$("#unit-cargo").hasClass("hidden"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_move_into_peaceful_territory_prompts_for_war() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const goal = { x: 900, y: 900 };
|
||
screen.map.territory.set("900,900", 1);
|
||
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
|
||
let predicted = null;
|
||
screen.map.predictMove = (u, coords) => {
|
||
predicted = { u, coords };
|
||
};
|
||
await screen.map.onMoveOrdered([unit.id], goal);
|
||
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
|
||
this.assertEmpty(network.orders, "nothing is sent before confirming");
|
||
this.assertTrue(env.$("#confirm-message").text().includes(state.civilisations[1].name));
|
||
|
||
env.$("#confirm-ok").click();
|
||
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] });
|
||
this.assertNotNull(predicted, "the confirmed move is predicted locally");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_cancelling_the_war_prompt_sends_nothing() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const goal = { x: 902, y: 902 };
|
||
screen.map.territory.set("902,902", 1);
|
||
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
|
||
await screen.map.onMoveOrdered([unit.id], goal);
|
||
env.$("#confirm-cancel").click();
|
||
this.assertEmpty(network.orders, "cancelling keeps the peace");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_move_into_enemy_territory_skips_the_prompt() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.requestDeclareWar(0, 1);
|
||
screen.onState(state.snapshot(0));
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const goal = { x: 901, y: 901 };
|
||
screen.map.territory.set("901,901", 1);
|
||
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
|
||
screen.map.predictMove = () => null;
|
||
await screen.map.onMoveOrdered([unit.id], goal);
|
||
this.assertTrue(env.$("#modal-confirm").hasClass("hidden"));
|
||
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] });
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_diplomacy_tab_sends_a_declare_war_order() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
|
||
const row = env.$("#diplomacy-list tbody tr:not(.local-nation)").first();
|
||
this.assertTrue(row.find(".name").text().length > 0);
|
||
row.find("button").click();
|
||
this.assertEqual(network.orders[0].type, "declare_war");
|
||
this.assertEqual(network.orders[0].target, 1);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_focusing_a_technology_sends_the_order() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab=technology]").click();
|
||
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
|
||
env.$("#technology-list .row-card")
|
||
.eq(method)
|
||
.find(".actions button")
|
||
.click();
|
||
this.assertEqual(network.orders[0], {
|
||
type: "focus_technology",
|
||
technology: method,
|
||
});
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_news_feed_shows_declarations() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.requestDeclareWar(0, 1);
|
||
screen.onState(state.snapshot(0));
|
||
env.$("#news-button").click();
|
||
this.assertFalse(env.$("#modal-news").hasClass("hidden"));
|
||
this.assertTrue(env.$("#news-list").text().includes(`declared war on ${state.civilisations[1].name}`));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_new_news_pops_up_in_the_corner_feed() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
// The first snapshot is history: it must not flood the feed.
|
||
screen.onState(state.snapshot(0));
|
||
this.assertSize(env.$("#game-feed .feed-line"), 0);
|
||
|
||
state.requestDeclareWar(0, 1);
|
||
screen.onState(state.snapshot(0));
|
||
this.assertSize(env.$("#game-feed .feed-line"), 1);
|
||
this.assertTrue(env.$("#game-feed .feed-line").text().includes(`declared war on ${state.civilisations[1].name}`));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_war_declared_on_the_player_is_an_alert() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
|
||
state.requestDeclareWar(1, 0);
|
||
screen.onState(state.snapshot(0));
|
||
const alert = env.$("#game-feed .feed-line.feed-alert");
|
||
this.assertSize(alert, 1);
|
||
this.assertTrue(alert.text().includes("declared war on you"));
|
||
screen.leave();
|
||
this.assertSize(env.$("#game-feed .feed-line"), 0, "leaving clears the feed");
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_stack_menu_selects_all_and_splits_one() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
|
||
this.assertFalse(env.$("#stack-menu").hasClass("hidden"));
|
||
this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all");
|
||
|
||
env.$("#stack-menu .stack-all").click();
|
||
this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]);
|
||
this.assertTrue(env.$("#unit-name").text().includes("2 units selected"));
|
||
this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick");
|
||
|
||
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
|
||
env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click();
|
||
this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_clicking_a_stacked_unit_selects_the_whole_stack() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
screen.map.onUnitSelected(unit.id);
|
||
this.assertEqual(
|
||
screen.selectedUnitIds.slice().sort((a, b) => a - b),
|
||
[unit.id, second.id].sort((a, b) => a - b),
|
||
"the whole stack is selected"
|
||
);
|
||
|
||
// The next order is given to every unit in the stack.
|
||
const goal = adjacentLand(state, unit);
|
||
await screen.map._onRightClick(goal, {});
|
||
this.assertEqual(network.orders[0].type, "move");
|
||
this.assertEqual(
|
||
network.orders[0].units.slice().sort((a, b) => a - b),
|
||
[unit.id, second.id].sort((a, b) => a - b)
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_group_move_uses_the_slowest_speed() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const capital = state.units.find((u) => u.civ === 0);
|
||
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
|
||
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
screen._selectUnits([infantry.id, artillery.id]);
|
||
const goal = adjacentLand(state, capital);
|
||
await screen.map.onMoveOrdered([infantry.id, artillery.id], goal);
|
||
|
||
this.assertEqual(network.orders[0].type, "move");
|
||
this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]);
|
||
const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0];
|
||
const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0];
|
||
this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_pillage_button_forecasts_and_sends_the_order() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const coords = findEnemyTile(state);
|
||
this.assertNotNull(coords, "found an enemy tile");
|
||
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
|
||
const first = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
||
const second = state._spawnUnit(coords, 0, state.protoUnits[0]);
|
||
first.hp = 100;
|
||
second.hp = 90;
|
||
screen.onState(state.snapshot(0));
|
||
|
||
screen._selectUnits([first.id, second.id]);
|
||
const $button = env.$("#unit-pillage");
|
||
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
|
||
const tooltip = $button.attr("data-tooltip") || "";
|
||
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
|
||
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
|
||
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
|
||
|
||
$button.click();
|
||
this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] });
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_pillage_button_hides_in_friendly_land() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
screen.onState(state.snapshot(0));
|
||
screen._selectUnits([unit.id]);
|
||
this.assertTrue(env.$("#unit-pillage").hasClass("hidden"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_politics_tab_sends_campaign_and_policy_orders() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||
this.assertTrue(env.$("#tab-politics").hasClass("active"));
|
||
this.assertGreater(env.$("#politics-list .politics-card").length, 0);
|
||
|
||
const $start = env.$("#tab-politics button").filter(
|
||
(index, element) => env.$(element).text() === "Start campaign"
|
||
);
|
||
this.assertSize($start, 1);
|
||
$start.trigger("click");
|
||
const campaign = network.orders.find((order) => order.type === "campaign");
|
||
this.assertNotNull(campaign);
|
||
this.assertEqual(campaign.observer, 0);
|
||
this.assertEqual(campaign.targetKind, "government");
|
||
this.assertEqual(campaign.target, 1);
|
||
|
||
this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
|
||
const $new = env.$("#tab-politics button").filter(
|
||
(index, element) => env.$(element).text() === "New minority policy"
|
||
);
|
||
this.assertSize($new, 1);
|
||
$new.trigger("click");
|
||
this.assertFalse(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
|
||
const $enactNow = env.$("#tab-politics button").filter(
|
||
(index, element) => env.$(element).text() === "Enact policy"
|
||
);
|
||
this.assertSize($enactNow, 1);
|
||
$enactNow.trigger("click");
|
||
const policy = network.orders.find((order) => order.type === "policy");
|
||
this.assertNotNull(policy);
|
||
this.assertEqual(policy.policy, "champion");
|
||
this.assertEqual(policy.ethnicity, 1);
|
||
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_politics_tab_survives_snapshot_refresh() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||
|
||
env
|
||
.$("#tab-politics button")
|
||
.filter((index, element) => env.$(element).text() === "New minority policy")
|
||
.trigger("click");
|
||
const $selects = env.$("#tab-politics .politics-wizard select");
|
||
$selects.eq(0).val("expel").trigger("change");
|
||
$selects.eq(1).val("1").trigger("change");
|
||
|
||
// A snapshot arrives while the panel is open: the wizard and its choices
|
||
// must not be torn down.
|
||
screen.onState(state.snapshot(0));
|
||
|
||
this.assertFalse(
|
||
env.$("#tab-politics .politics-wizard").hasClass("hidden"),
|
||
"the wizard stays open"
|
||
);
|
||
this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(0).val(), "expel");
|
||
this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(1).val(), "1");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_politics_cancel_campaign_and_duplicate_guard() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||
|
||
// Inject a running campaign owned by the player.
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
state.requestCampaign(0, 0, "government", 1, 1);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
const $cancel = env.$("#tab-politics button").filter(
|
||
(index, element) => env.$(element).text() === "Cancel campaign"
|
||
);
|
||
this.assertSize($cancel, 1);
|
||
$cancel.trigger("click");
|
||
this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign");
|
||
|
||
// The launcher now refuses to duplicate the running campaign.
|
||
const $start = env.$("#tab-politics button").filter(
|
||
(index, element) => env.$(element).text() === "Start campaign"
|
||
);
|
||
this.assertTrue($start.prop("disabled"), "duplicate campaign blocked");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_politics_campaign_spending_controls() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
state.requestCampaign(0, 0, "government", 1, 1);
|
||
screen.onState(state.snapshot(0));
|
||
|
||
const $slider = env.$("#tab-politics .politics-slider");
|
||
this.assertSize($slider, 1);
|
||
$slider.val(20000).trigger("change");
|
||
const spending = network.orders.find((order) => order.type === "campaign_spending");
|
||
this.assertNotNull(spending);
|
||
this.assertEqual(spending.amount, 20000);
|
||
|
||
// Pushing it to zero asks first, and only stops on confirmation.
|
||
network.orders.length = 0;
|
||
$slider.val(0).trigger("change");
|
||
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "confirmation asked");
|
||
this.assertNull(
|
||
network.orders.find((order) => order.type === "cancel_campaign"),
|
||
"nothing sent before confirming"
|
||
);
|
||
env.$("#confirm-ok").click();
|
||
this.assertNotNull(network.orders.find((order) => order.type === "cancel_campaign"));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_politics_opinion_breakdown() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
screen.onState(smallState().snapshot(0));
|
||
env.$("#civ-flag").click();
|
||
env.$("#modal-nation .tab[data-tab='politics']").click();
|
||
this.assertGreater(env.$("#tab-politics .opinion-row").length, 0);
|
||
const headings = env.$("#tab-politics .opinion-heading");
|
||
this.assertEqual(headings.eq(0).text(), "Governments");
|
||
this.assertEqual(headings.eq(1).text(), "Peoples");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_tile_detail_shows_ethnic_makeup() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const k = Array.from(state.tilePopulation.keys()).find((key) => {
|
||
const parts = key.split(",").map(Number);
|
||
return state._isLand({ x: parts[0], y: parts[1] });
|
||
});
|
||
const parts = k.split(",").map(Number);
|
||
screen._showTile({ x: parts[0], y: parts[1] });
|
||
this.assertGreater(env.$("#tile-ethnicity .ethnic-legend-row").length, 0);
|
||
this.assertTrue(env.$("#tile-ethnicity .ethnic-legend-row").first().text().includes("("));
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_city_people_tab_shows_ethnic_makeup() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
|
||
screen.map.onCitySelected(capital.id);
|
||
env.$("#city-manage").click();
|
||
env.$("#modal-city .tab[data-tab='people']").click();
|
||
this.assertTrue(env.$("#city-tab-people").hasClass("active"));
|
||
this.assertGreater(env.$("#city-people-list .ethnic-chart").length, 0);
|
||
this.assertGreater(env.$("#city-people-list .ethnic-legend-row").length, 0);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_improvement_buttons_send_build_and_remove_orders() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 10_000_000_000);
|
||
screen.onState(state.snapshot(0));
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
|
||
!state.roads.has(k) && !state.railways.has(k) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
screen._showTile(coords);
|
||
const options = env.$("#tile-improvement-actions .transport-btn");
|
||
this.assertEqual(options.length, 2, "road and railway are listed");
|
||
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
|
||
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
|
||
// Each is a square icon with its name in the tooltip and its resource and
|
||
// money cost beneath.
|
||
this.assertTrue(
|
||
options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
|
||
"the road button shows its icon"
|
||
);
|
||
const costText = options.eq(0).find(".improvement-cost").text();
|
||
this.assertTrue(costText.includes("5000 t"), `the steel cost is shown: ${costText}`);
|
||
this.assertTrue(costText.includes("100 GWh"), `the energy cost is shown: ${costText}`);
|
||
// The money figure is the build cost plus the material bill at today's
|
||
// market prices, not the raw build cost.
|
||
const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
|
||
const expectedCost = quotedBuildCost(
|
||
roadProto.buildCost,
|
||
tileImprovementResourceCost(roadProto),
|
||
marketPricesFrom(screen.snapshot)
|
||
);
|
||
this.assertTrue(
|
||
costText.includes(`£${compact(expectedCost)}`),
|
||
`the market price is shown: ${costText}`
|
||
);
|
||
this.assertEqual(
|
||
options.eq(0).find(".improvement-cost img.cost-icon").length,
|
||
2,
|
||
"steel and energy carry resource icons"
|
||
);
|
||
this.assertTrue(
|
||
options.eq(0).attr("title").startsWith("Road"),
|
||
"the name is the tooltip"
|
||
);
|
||
options.eq(0).click();
|
||
const order = network.orders[network.orders.length - 1];
|
||
this.assertEqual(order.type, "build_improvement");
|
||
this.assertApprox(order.coords[0], coords.x);
|
||
this.assertApprox(order.coords[1], coords.y);
|
||
this.assertEqual(order.improvement, "road");
|
||
|
||
// A tile that already carries the improvement greys it out, keeps the
|
||
// upgrade and offers a remove control. The seeded starter industry also
|
||
// puts works on network tiles, so clear any off this one first.
|
||
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
|
||
const roadKey = `${road[0]},${road[1]}`;
|
||
state.tileImprovements.delete(roadKey);
|
||
state.tileImprovementOwner.delete(roadKey);
|
||
state.tileImprovementHp.delete(roadKey);
|
||
screen.onState(state.snapshot(0));
|
||
screen._showTile({ x: road[0], y: road[1] });
|
||
const again = env.$("#tile-improvement-actions .transport-btn").not(".remove");
|
||
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
|
||
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
|
||
const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
|
||
this.assertSize($remove, 1, "a remove control is offered");
|
||
$remove.click();
|
||
this.assertEqual(network.orders[network.orders.length - 1], {
|
||
type: "remove_improvement",
|
||
coords: [road[0], road[1]],
|
||
});
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_improvement_buttons_are_grouped_by_category() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
screen.onState(state.snapshot(0));
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
screen._showTile(coords);
|
||
const labels = env.$("#tile-improvement-actions .improvement-category")
|
||
.map((_, el) => env.$(el).text())
|
||
.get();
|
||
this.assertEqual(
|
||
labels.join(","),
|
||
"Military,Transportation,Economic",
|
||
"the categories are labelled in order"
|
||
);
|
||
const firstClasses = env.$("#tile-improvement-actions .improvement-row")
|
||
.map((_, el) => env.$(el).find(".improvement-btn").first().attr("class"))
|
||
.get();
|
||
this.assertTrue(firstClasses[0].includes("tile-btn"), "military works come first");
|
||
this.assertTrue(firstClasses[1].includes("transport-btn"), "transport comes second");
|
||
this.assertTrue(firstClasses[2].includes("resource-btn"), "economic producers come last");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_a_negative_budget_never_greys_out_an_improvement() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
// The treasury is far in the red: any order is financed by the central
|
||
// bank as debt, so the buttons must stay live.
|
||
state.budgets.set(0, -5_000_000);
|
||
screen.onState(state.snapshot(0));
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
screen._showTile(coords);
|
||
this.assertFalse(
|
||
env.$("#tile-improvement-actions .transport-btn").eq(0).prop("disabled"),
|
||
"transport stays clickable in debt"
|
||
);
|
||
const military = env.$("#tile-improvement-actions .improvement-row")
|
||
.eq(0).find(".tile-btn").first();
|
||
this.assertFalse(military.prop("disabled"), "a military work stays clickable in debt");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_a_tile_under_construction_offers_only_a_cancel() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
this.assertTrue(
|
||
state.requestBuildTileImprovement(0, coords, "radar_tower"),
|
||
"the work is ordered"
|
||
);
|
||
screen.onState(state.snapshot(0));
|
||
screen._showTile(coords);
|
||
// Every improvement button is gone while the work runs.
|
||
this.assertSize(env.$("#tile-improvement-actions .improvement-group"), 0, "nothing is left");
|
||
this.assertSize(env.$("#tile-improvement-actions .tile-btn"), 0, "no military or economic works");
|
||
this.assertSize(env.$("#tile-improvement-actions .transport-btn"), 0, "not even transport");
|
||
// The only control is the cancel beside the progress.
|
||
const $cancel = env.$("#tile-works .site-actions button");
|
||
this.assertSize($cancel, 1, "the work can still be cancelled");
|
||
this.assertTrue($cancel.text().includes("Cancel"), `it cancels the work: ${$cancel.text()}`);
|
||
$cancel.click();
|
||
const order = network.orders[network.orders.length - 1];
|
||
this.assertEqual(order.type, "cancel_site");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_a_stalled_site_raises_its_budget_without_a_button() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
|
||
// Force a stall with materials still to buy and the market up sharply,
|
||
// but a supplier within reach so the budget can climb.
|
||
const site = state.constructionSiteAt(coords);
|
||
site.budget = 1000;
|
||
site.phase = "materials";
|
||
site.needed.steel = 100;
|
||
site.bought.steel = 0;
|
||
site.stalled = true;
|
||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 10);
|
||
const snapshot = state.snapshot(0);
|
||
const entry = (snapshot.constructionSites[0] || []).find(
|
||
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
|
||
);
|
||
entry.unreachable = [];
|
||
screen.onState(snapshot);
|
||
screen._showTile(coords);
|
||
const labels = env.$("#tile-works .site-material-label")
|
||
.map((_, el) => env.$(el).text())
|
||
.get()
|
||
.join("|");
|
||
this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`);
|
||
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
|
||
this.assertEqual(
|
||
env.$("#tile-works .site-warning").length,
|
||
0,
|
||
"a reachable site keeps no warning sign"
|
||
);
|
||
const $actions = env.$("#tile-works .site-actions button");
|
||
this.assertEqual($actions.length, 1, "only one control is left");
|
||
this.assertTrue(
|
||
$actions.eq(0).text().includes("Cancel"),
|
||
`cancelling stays: ${$actions.eq(0).text()}`
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_a_stalled_site_with_no_supplier_says_so() {
|
||
const env = await setupDom();
|
||
try {
|
||
const network = stubNetwork();
|
||
const screen = new GameScreen(network, makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
|
||
const site = state.constructionSiteAt(coords);
|
||
site.budget = 1000;
|
||
site.phase = "materials";
|
||
site.needed.steel = 100;
|
||
site.bought.steel = 0;
|
||
site.stalled = true;
|
||
const snapshot = state.snapshot(0);
|
||
const entry = (snapshot.constructionSites[0] || []).find(
|
||
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
|
||
);
|
||
// No reachable source holds the steel: no budget can unstall the work, so
|
||
// this is the only case that keeps the warning sign.
|
||
entry.unreachable = ["steel"];
|
||
screen.onState(snapshot);
|
||
screen._showTile(coords);
|
||
const labels = env.$("#tile-works .site-material-label")
|
||
.map((_, el) => env.$(el).text())
|
||
.get()
|
||
.join("|");
|
||
this.assertTrue(labels.includes("No supplier"), `the missing supplier is shown: ${labels}`);
|
||
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
|
||
this.assertEqual(
|
||
env.$("#tile-works .site-warning").length,
|
||
1,
|
||
"the warning sign marks the missing supplier"
|
||
);
|
||
const $actions = env.$("#tile-works .site-actions button");
|
||
this.assertEqual($actions.length, 1, "only the cancel button remains");
|
||
this.assertTrue($actions.eq(0).text().includes("Cancel"), "cancelling still works");
|
||
$actions.eq(0).click();
|
||
this.assertEqual(
|
||
network.orders[network.orders.length - 1],
|
||
{ type: "cancel_site", coords: [coords.x, coords.y] }
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_improvement_cost_tracks_market_prices() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
screen.onState(state.snapshot(0));
|
||
screen._showTile(coords);
|
||
const before = env.$("#tile-improvement-actions .transport-btn").eq(0)
|
||
.find(".improvement-cost").text();
|
||
// A jump in the world steel price raises the quoted price, without
|
||
// changing the amount of steel shown.
|
||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 3);
|
||
screen.onState(state.snapshot(0));
|
||
screen._showTile(coords);
|
||
const after = env.$("#tile-improvement-actions .transport-btn").eq(0)
|
||
.find(".improvement-cost").text();
|
||
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
|
||
this.assertTrue(after.includes("5000 t"), "the steel amount is unchanged");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_tile_panel_shows_improvement_build_progress() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const coords = state.landCells.find((cell) => {
|
||
const k = `${cell.x},${cell.y}`;
|
||
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
|
||
!state.unitAt(cell) && !state.tileImprovements.has(k);
|
||
});
|
||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||
this.assertTrue(
|
||
state.requestBuildTileImprovement(0, coords, "radar_tower"),
|
||
"the work is queued"
|
||
);
|
||
state.advanceHour();
|
||
screen.onState(state.snapshot(0));
|
||
screen._showTile(coords);
|
||
const label = env.$("#tile-works .city-production-label").text();
|
||
this.assertTrue(label.startsWith("Building Radar tower"), `the work is named: ${label}`);
|
||
this.assertGreater(env.$("#tile-works .progress").length, 0, "a progress bar is drawn");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_double_clicking_a_city_opens_its_panel() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
screen.onState(state.snapshot(0));
|
||
const marker = env.$(`#layer-entities .city[data-city-id='${capital.id}'] .marker`).get(0);
|
||
this.assertNotNull(marker, "the capital is drawn");
|
||
// One click selects the city and shows its quick panel.
|
||
env.$(marker).trigger("click");
|
||
this.assertFalse(env.$("#city-info").hasClass("hidden"), "a click opens the quick panel");
|
||
this.assertTrue(env.$("#modal-city").hasClass("hidden"), "a click does not open the modal");
|
||
// The second click opens the full city panel.
|
||
marker.dispatchEvent(
|
||
new env.window.MouseEvent("dblclick", { bubbles: true, cancelable: true })
|
||
);
|
||
this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city panel opens");
|
||
this.assertTrue(
|
||
env.$("#city-title").text().startsWith(capital.name),
|
||
"the panel is for the clicked city"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_clicking_a_unit_also_opens_its_tile_panel() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onUnitSelected(unit.id);
|
||
this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens");
|
||
this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens too");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_stack_panel_checklist_narrows_the_selection() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const unit = state.units.find((u) => u.civ === 0);
|
||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||
screen.onState(state.snapshot(0));
|
||
screen._selectUnits([unit.id, second.id]);
|
||
this.assertSize(env.$("#unit-stack-list input"), 2, "each stacked unit is listed");
|
||
env.$("#unit-stack-list input").eq(0).prop("checked", false).trigger("change");
|
||
this.assertEqual(screen.selectedUnitIds.length, 1, "unticking narrows the selection");
|
||
this.assertEqual(screen.selectedUnitIds[0], second.id);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_city_tile_panel_shows_current_production() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
state.budgets.set(0, 1.0e15);
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
grantBuilding(state, capital, "barracks");
|
||
state.requestTrain(capital.id, 0);
|
||
state.training.get(capital.id)[0].elapsedHours = state.training.get(capital.id)[0].totalHours / 2;
|
||
screen.onState(state.snapshot(0));
|
||
screen.map.onCitySelected(capital.id);
|
||
this.assertTrue(
|
||
env.$("#city-production").text().includes("Training"),
|
||
"the city panel shows what is in production"
|
||
);
|
||
this.assertTrue(
|
||
parseFloat(env.$("#city-production .city-production-bar > div").css("width")) > 0,
|
||
"the quick view has a progress bar"
|
||
);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_stat_tooltips_show_the_long_run_growth() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const snapshot = state.snapshot(0);
|
||
snapshot.viewerStats.growth = { sinceStart: true, population: 0.0123, gdp: 0.02, gdpPerCapita: 0.01 };
|
||
screen.onState(snapshot);
|
||
const tip = (id) => env.$(`#${id}`).closest(".stat").attr("data-tooltip");
|
||
this.assertEqual(tip("stat-pop"), "since 1 January 2000: +1.23%");
|
||
this.assertEqual(tip("stat-gdp"), "since 1 January 2000: +2.00%");
|
||
this.assertEqual(tip("stat-gdppc"), "since 1 January 2000: +1.00%");
|
||
// A year on, the wording and the baseline both change.
|
||
snapshot.viewerStats.growth = { sinceStart: false, population: -0.005, gdp: 0.0, gdpPerCapita: 0.004 };
|
||
screen.onState(snapshot);
|
||
this.assertEqual(tip("stat-pop"), "year on year: −0.50%");
|
||
this.assertEqual(tip("stat-gdp"), "year on year: +0.00%");
|
||
// A snapshot without a figure leaves no stale tooltip behind.
|
||
snapshot.viewerStats.growth = null;
|
||
screen.onState(snapshot);
|
||
this.assertNull(tip("stat-pop"), "no growth means no tooltip");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_every_summary_stat_uses_the_same_custom_tooltip() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
const snapshot = state.snapshot(0);
|
||
screen.onState(snapshot);
|
||
const stats = snapshot.viewerStats;
|
||
// The budget forecasts the next calendar month at the current hourly net.
|
||
const expected = stats.breakdown.net * hoursInNextMonth(snapshot.totalHours);
|
||
const sign = expected < 0 ? "−" : "+";
|
||
this.assertEqual(
|
||
env.$("#stat-budget").closest(".stat").attr("data-tooltip"),
|
||
`${sign}£${compact(Math.abs(expected))}/mo`
|
||
);
|
||
// Popularity speaks through the same bubble, not a native title.
|
||
const $approval = env.$("#stat-approval").closest(".stat");
|
||
this.assertNotNull($approval.attr("data-tooltip"), "popularity has a custom tooltip");
|
||
this.assertNull($approval.attr("title"), "popularity has no native title");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_the_tile_panel_lists_produced_resources() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
const state = smallState();
|
||
screen.onState(state.snapshot(0));
|
||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||
screen._showTile({ x: capital.coords.x, y: capital.coords.y });
|
||
const $food = env.$("#tile-production .tile-prod-row").filter((_, el) =>
|
||
env.$(el).text().includes("Food")
|
||
);
|
||
this.assertEqual($food.length, 1, "the capital grows food");
|
||
this.assertTrue($food.text().includes("/day"), "its output is per day");
|
||
// The modifiers that shape the output ride along in the row's tooltip.
|
||
this.assertNotNull($food.attr("title"), "the row explains its modifiers");
|
||
this.assertTrue($food.attr("title").includes("Capital"), `cites the capital bonus: ${$food.attr("title")}`);
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
|
||
async test_air_strike_burst_fires_on_the_bomb_run_not_the_flight() {
|
||
const env = await setupDom();
|
||
try {
|
||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||
screen.enter();
|
||
screen.protoUnits = PROTO_UNITS;
|
||
screen.map.tileInView = () => true;
|
||
const airIndex = PROTO_UNITS.findIndex((proto) => proto.air);
|
||
const groundIndex = PROTO_UNITS.findIndex((proto) => proto.military && !proto.air);
|
||
const triggers = [];
|
||
screen.airBombing.trigger = (volume) => triggers.push(volume);
|
||
|
||
// The first snapshot only primes the baseline: no burst while the plane
|
||
// is still flying towards its target.
|
||
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
|
||
this.assertEmpty(triggers, "nothing while the plane is en route");
|
||
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
|
||
this.assertEmpty(triggers, "still nothing while it flies");
|
||
|
||
// Reaching the target clears the run: the bombs fall.
|
||
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: null }] });
|
||
this.assertSize(triggers, 1, "the resolved bomb run triggers the burst");
|
||
|
||
// A plane shot down before bombing is gone, not detonated.
|
||
screen._detectAirStrikes({ units: [{ id: 2, proto: airIndex, strikeTarget: [6, 6] }] });
|
||
screen._detectAirStrikes({ units: [] });
|
||
this.assertSize(triggers, 1, "a downed plane does not bomb");
|
||
|
||
// An off-screen bomb run is silent.
|
||
screen.map.tileInView = () => false;
|
||
screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: [7, 7] }] });
|
||
screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: null }] });
|
||
this.assertSize(triggers, 1, "the burst only plays in view");
|
||
|
||
// Aircraft do not drive the ambient barrage, ground batteries still do.
|
||
screen.map.tileInView = () => true;
|
||
screen.snapshot = { units: [{ id: 4, proto: airIndex, strikeTarget: [1, 1] }] };
|
||
this.assertFalse(screen._bombingInView(), "aircraft stay out of the ambience");
|
||
screen.snapshot = { units: [{ id: 5, proto: groundIndex, strikeTarget: [1, 1] }] };
|
||
this.assertTrue(screen._bombingInView(), "a ground battery still rumbles");
|
||
screen.leave();
|
||
} finally {
|
||
teardownDom(env);
|
||
}
|
||
}
|
||
}
|
||
|
||
function findEnemyTile(state) {
|
||
for (const [k, owner] of state.territory) {
|
||
if (owner !== 1) continue;
|
||
const i = k.indexOf(",");
|
||
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
|
||
if (state.unitAt(coords) || state.cityAt(coords)) continue;
|
||
return coords;
|
||
}
|
||
return null;
|
||
}
|