Files
Battle-for-Tismo/tests/game_screen_test.js
adrien da11e1d97d Showed the culture a propaganda campaign drains
The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs.
2026-09-28 10:15:22 +02:00

2901 lines
115 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, SPY_OPERATIONS, TRANSPORT_IMPROVEMENTS, TREATY_PEACE } from "../shared/data.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact, groupDigits } 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, simpleState, 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]],
};
}
// Switches the open Intelligence tab to one of its sub-tabs, by label.
function showIntelligenceSubtab(env, label) {
env
.$("#intelligence-subtabs .subtab")
.filter((index, element) => env.$(element).text() === label)
.trigger("click");
}
// The first populated foreign land tile in the fixture, for a spy to stand on.
function foreignPopulatedLand(state, civ = 1) {
for (const [k, owner] of state.territory) {
if (owner !== civ) continue;
const coords = parseKey(k);
if (state.cityAt(coords)) continue;
if (state.unitsAt(coords).length > 0) continue;
const tile = state.tiles[k];
if (!tile || tile.terrainClass !== "Land") continue;
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
return coords;
}
return null;
}
// The city-training cards whose action is live (a locked card's Train button is
// disabled), so counts ignore the gated units shown for reference.
function enabledTrainCards(env) {
return env.$("#city-train-list .building-tile").filter(
(index, element) => !env.$(element).find(".bt-upgrade").prop("disabled")
);
}
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(enabledTrainCards(env), 4, "infantry, artillery, radar and missiles can be trained");
enabledTrainCards(env).first().find(".bt-upgrade").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 .tech-card"),
TECHNOLOGIES.length,
"the whole technology graph is drawn"
);
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 .building-tile"), 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_spies_are_trainable_once_the_agency_is_researched() {
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));
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
const spyCard = () =>
env.$("#city-train-list .building-tile").filter(
(index, element) => env.$(element).find(".bt-name").first().text() === "Spy"
);
this.assertSize(spyCard(), 1, "the spy is listed even before the agency");
this.assertTrue(spyCard().find(".bt-upgrade").prop("disabled"), "but its card is locked");
this.assertTrue(
spyCard().text().includes("Requires Intelligence Agencies"),
"and names what it needs"
);
const tech = state.technologies.findIndex((t) => t.id === "intelligence_agencies");
state.researched.get(0).add(tech);
screen.onState(state.snapshot(0));
this.assertFalse(
spyCard().find(".bt-upgrade").prop("disabled"),
"the spy is trainable after the agency"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_testing_mode_offers_instant_technology_unlock() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.testing = true;
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='technology']").click();
const $unlock = env.$("#technology-list .free-button").filter(
(index, element) => env.$(element).text() === "Unlock now"
);
this.assertGreater($unlock.length, 0, "a testing game offers an instant unlock");
$unlock.first().trigger("click");
const order = network.orders.find((entry) => entry.type === "unlock_technology");
this.assertNotNull(order, "the unlock order is sent");
this.assertTrue(order.free, "flagged as the testing shortcut");
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_on_a_fogged_neutral_city_asks_to_declare_war() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((c) => c.civ === 0);
grantBuilding(state, home, "airport");
const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter");
const jet = state._spawnUnit(home.coords, 0, jetProto, home.id);
const city = state.cities.find((c) => c.civ === 1);
// Defenceless and under the fog: no garrison is visible, but a known city
// tile is still a legitimate air-strike target.
for (const unit of state.unitsAt(city.coords).slice()) state._destroyUnit(unit);
screen.onState(state.snapshot(0));
screen._selectUnits([jet.id]);
this.assertTrue(
screen._isEnemyCityAt({ x: city.coords.x, y: city.coords.y }),
"the fogged city is a target"
);
screen._toggleTargeting();
this.assertNotNull(screen._targeting, "targeting is armed");
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
for (let i = 0; i < 100 && env.$("#confirm-title").text() !== "Declare war?"; i++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
this.assertEqual(
env.$("#confirm-title").text(),
"Declare war?",
"attacking a neutral city asks first, exactly like marching in"
);
env.$("#confirm-ok").trigger("click");
const order = network.orders.find((entry) => entry.type === "attack");
this.assertNotNull(order, "confirming sends the air strike");
this.assertEqual(order.coords[0], city.coords.x);
this.assertEqual(order.coords[1], city.coords.y);
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 = enabledTrainCards(env).length;
this.assertEqual(before, 4, "only infantry, artillery, radar and missiles 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(
enabledTrainCards(env).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"), 4, "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_chat_opens_with_t_and_sends_lines() {
const env = await setupDom();
try {
const network = stubNetwork();
const sent = [];
network.sendChat = (text) => sent.push(text);
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
this.assertTrue(env.$("#chat").hasClass("hidden"), "the chat starts closed");
env.$(env.window).trigger(env.$.Event("keydown", { key: "t" }));
this.assertFalse(env.$("#chat").hasClass("hidden"), "T opens the chat");
// The map must not pan while the chat input holds focus.
const cameraBefore = screen.map.camera.x;
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "d" }));
this.assertApprox(screen.map.camera.x, cameraBefore, 1e-9, "typing does not pan the map");
screen.addChat({ name: "Bob", civ: 1, text: "hello there" });
const line = env.$("#chat-log .chat-line").last();
this.assertTrue(line.find(".chat-author").text().includes("Bob"));
this.assertTrue(line.find(".chat-text").text().includes("hello there"));
env.$("#chat-input").val("good luck");
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Enter" }));
this.assertEqual(sent, ["good luck"], "the line is sent to the server");
this.assertEqual(env.$("#chat-input").val(), "", "the input clears after sending");
env.$("#chat-input").trigger(env.$.Event("keydown", { key: "Escape" }));
this.assertTrue(env.$("#chat").hasClass("hidden"), "Escape closes the chat");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_treaty_offer_becomes_a_clickable_feed_line() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
// The first snapshot only seeds the seen-news baseline.
screen.onState(state.snapshot(0));
state.requestProposeTreaty(1, 0, TREATY_PEACE);
screen.onState(state.snapshot(0));
const line = env.$("#game-feed .feed-line").last();
this.assertTrue(line.text().includes("proposes Peace"), "the offer hits the feed");
this.assertTrue(line.hasClass("feed-clickable"), "and can be clicked");
line.trigger("click");
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the offer dialog opens");
this.assertTrue(env.$("#confirm-ok").text().includes("Sign"), "with a Sign button");
env.$("#confirm-ok").trigger("click");
const order = network.orders.at(-1);
this.assertEqual(order.type, "respond_treaty");
this.assertTrue(order.accept, "Signing accepts the offer");
screen.leave();
} 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 === "renewable_energy");
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_stack_deck_is_laid_out_after_the_panel_is_shown() {
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);
state._spawnUnit(home.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const snap = screen.snapshot.units.find((u) => u.civ === 0);
// Records whether the panel was already on screen when the deck was
// measured, so a hidden panel (zero-width cards, no overlap) is caught.
let visibleAtLayout = null;
const layout = screen._layoutStackDecks.bind(screen);
screen._layoutStackDecks = () => {
visibleAtLayout = !env.$("#unit-info").hasClass("hidden");
layout();
};
screen.map.onUnitSelected(snap.id);
this.assertGreater(env.$("#unit-stack-list .stack-unit-tile").length, 1, "the deck rendered");
this.assertTrue(visibleAtLayout, "the deck is measured only once the panel is shown");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_shift_detach_splits_the_stack_into_a_moving_group() {
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 a = home;
const b = state._spawnUnit(home.coords, 0, state.protoUnits[0]);
const c = state._spawnUnit(home.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const sorted = (list) => list.slice().sort((x, y) => x - y);
const all = sorted([a.id, b.id, c.id]);
screen.map.onUnitSelected(a.id);
this.assertEqual(sorted(screen.selectedUnitIds), all, "a click takes the whole stack");
screen.map.onUnitToggled(b.id);
this.assertEqual(
sorted(screen.selectedUnitIds),
sorted([a.id, c.id]),
"shift detaches one unit from the moving group"
);
this.assertTrue(
screen._stackMembers.includes(b.id),
"the detached card stays in the panel, so a mis-click is recoverable"
);
screen.map.onUnitToggled(b.id);
this.assertEqual(screen.selectedUnitIds.length, 3, "toggling it again re-attaches it");
screen.map.onUnitToggled(b.id);
const goal = state.topology
.neighbours(home.coords.x, home.coords.y)
.find((n) => state._isLand(n) && state.civAt(n) <= 0 && !state.unitAt(n));
this.assertNotNull(goal, "the fixture has an empty own-land neighbour");
await screen.map.onMoveOrdered(screen.selectedUnitIds.slice(), goal);
const order = network.orders.at(-1);
this.assertEqual(order.type, "move");
this.assertEqual(
sorted(order.units),
sorted([a.id, c.id]),
"only the ticked subset marches"
);
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 quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
env.$(`#technology-list .tech-card[data-index="${quantum}"]`).click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(network.orders[0], {
type: "focus_technology",
technology: quantum,
});
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\u00a0000"), `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_propaganda_tab_shows_the_culture_drain() {
const env = await setupDom();
try {
const state = simpleState();
state.culture.set(0, 12_345);
state.requestCampaign(0, 0, "government", 1, 1);
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
const $culture = env.$("#intelligence-propaganda-view .culture-readout");
this.assertSize($culture, 1, "the propaganda tab reads out the culture stock");
this.assertTrue($culture.text().includes("12\u00a0345"), "it names the current culture");
this.assertTrue($culture.text().includes("192"), "and what the campaigns consume");
const src = $culture.find("img").first().attr("src");
this.assertTrue(/icon_culture\.svg$/.test(src || ""), "with the culture icon");
const $cost = env.$("#intelligence-propaganda-view .politics-campaign-cost");
this.assertSize($cost, 1, "the running campaign reports its cost");
this.assertGreater($cost.find("img").length, 0, "in culture points");
this.assertFalse($cost.text().includes("\u20ac"), "not in money");
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='intelligence']").click();
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
showIntelligenceSubtab(env, "Propaganda");
this.assertGreater(env.$("#intelligence-propaganda-view .politics-card").length, 0);
const $start = env.$("#intelligence-propaganda-view 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);
env.$("#modal-nation .tab[data-tab='politics']").click();
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_intelligence_tab_hosts_propaganda() {
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='intelligence']").click();
this.assertTrue(env.$("#tab-intelligence").hasClass("active"));
showIntelligenceSubtab(env, "Propaganda");
const $launcher = env.$("#intelligence-propaganda-view button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($launcher, 1, "the propaganda launcher lives on the Intelligence tab");
const $onPolitics = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($onPolitics, 0, "and is no longer on the Politics tab");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_intelligence_subtabs_split_spies_and_propaganda() {
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='intelligence']").click();
const $tabs = env.$("#intelligence-subtabs .subtab");
this.assertSize($tabs, 2, "the Intelligence tab has two sub-tabs");
this.assertEqual($tabs.eq(0).text(), "Spies");
this.assertEqual($tabs.eq(1).text(), "Propaganda");
// The spy controls open first, the campaigns stay out of the way.
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
showIntelligenceSubtab(env, "Propaganda");
this.assertTrue(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertFalse(env.$("#intelligence-propaganda-view").hasClass("hidden"));
showIntelligenceSubtab(env, "Spies");
this.assertFalse(env.$("#intelligence-spies-view").hasClass("hidden"));
this.assertTrue(env.$("#intelligence-propaganda-view").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_intelligence_tab_lists_the_viewers_spies_and_locates_them() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $spies = env.$("#intelligence-spies-view .politics-card").filter(
(index, element) => env.$(element).find(".name").first().text() === "Your spies"
);
this.assertSize($spies, 1, "the roster card is on the Intelligence tab");
this.assertTrue($spies.text().includes("Spy 1"), "the spy is listed");
this.assertTrue($spies.text().includes(home.name), "with the city it stands in");
const $locate = $spies.find("button").filter(
(index, element) => env.$(element).text() === "Locate"
);
this.assertSize($locate, 1, "the roster offers a Locate control");
$locate.trigger("click");
this.assertTrue(screen.selectedUnitIds.includes(spy.id), "Locate selects the spy");
this.assertFalse(screen.nationModal.isShowing(), "and closes the nation panel");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_spy_actions_live_in_the_unit_panel() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const home = state.cities.find((city) => city.civ === 0);
const spy = state._spawnUnit(home.coords, 0, state.protoUnits.find((u) => u.id === "spy"));
const soldier = state._spawnUnit(
home.coords,
0,
state.protoUnits.find((u) => u.id === "modern_infantry")
);
screen.onState(state.snapshot(0));
// A spy's covert operations are unit actions, so they appear in the unit
// panel once the spy is selected.
screen._selectUnits([spy.id]);
const $actions = env.$("#unit-spy-actions");
this.assertFalse($actions.hasClass("hidden"), "the spy actions row shows for a spy");
const $buttons = $actions.find("button");
this.assertSize($buttons, SPY_OPERATIONS.length, "one button per operation");
for (const operation of SPY_OPERATIONS) {
const $button = $buttons.filter(
(index, element) => env.$(element).attr("data-operation") === operation.id
);
this.assertSize($button, 1, `a button for ${operation.id}`);
this.assertTrue($button.prop("disabled"), `${operation.id} is not wired yet`);
}
// A soldier gets no spy actions.
screen._selectUnits([soldier.id]);
this.assertTrue(
env.$("#unit-spy-actions").hasClass("hidden"),
"hidden for a soldier"
);
// And the tab no longer lists the operations.
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $operations = env.$("#intelligence-spies-view .politics-card").filter(
(index, element) => env.$(element).find(".name").first().text() === "Covert operations"
);
this.assertSize($operations, 0, "the operations card is not on the Intelligence tab");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_sabotage_is_offered_on_foreign_land_and_sent() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const target = foreignPopulatedLand(state, 1);
this.assertNotNull(target, "the fixture has a populated foreign tile");
const spy = state._spawnUnit(target, 0, state.protoUnits.find((u) => u.id === "spy"));
screen.onState(state.snapshot(0));
screen._selectUnits([spy.id]);
const $sabotage = env.$("#unit-spy-actions").find("[data-operation='sabotage']");
this.assertSize($sabotage, 1, "the sabotage button exists");
this.assertFalse($sabotage.prop("disabled"), "sabotage is offered on foreign land");
$sabotage.trigger("click");
const order = network.orders.find((entry) => entry.type === "sabotage");
this.assertNotNull(order, "the sabotage order is sent");
this.assertTrue(order.units.includes(spy.id), "for the selected spy");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_city_spying_arms_targeting_and_sends() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 1);
const spy = state._spawnUnit(
{ x: city.coords.x, y: city.coords.y },
0,
state.protoUnits.find((u) => u.id === "spy")
);
screen.onState(state.snapshot(0));
screen._selectUnits([spy.id]);
const $button = env.$("#unit-spy-actions").find("[data-operation='city_spying']");
this.assertSize($button, 1);
this.assertFalse($button.prop("disabled"), "a city is in reach");
$button.trigger("click");
this.assertNotNull(screen._targeting, "picking a target is armed");
screen._chooseTarget({ x: city.coords.x, y: city.coords.y });
const order = network.orders.find((entry) => entry.type === "spy_city");
this.assertNotNull(order, "the city spy order is sent");
this.assertEqual(order.coords[0], city.coords.x);
this.assertTrue(order.units.includes(spy.id));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_launch_satellite_button_sends() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const city = state.cities.find((entry) => entry.civ === 0);
grantBuilding(state, city, "space_launch_center", 1);
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
const $launch = env.$("#intelligence-spies-view button").filter(
(index, element) => env.$(element).text() === "Launch spy satellite"
);
this.assertSize($launch, 1, "the launch control is on the Spies sub-tab");
this.assertFalse($launch.prop("disabled"), "a free pad is available");
$launch.trigger("click");
const order = network.orders.find((entry) => entry.type === "launch_satellite");
this.assertNotNull(order, "the launch order is sent");
this.assertEqual(order.city, city.id);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_spy_boards_a_launch_on_a_right_click() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
let land = null;
let sea = null;
for (const cell of state.landCells) {
if (state.civAt(cell) !== 0 || state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
const tile = state.tiles[`${neighbour.x},${neighbour.y}`];
if (tile && tile.terrainClass === "Sea" && !state.unitAt(neighbour)) {
land = cell;
sea = neighbour;
break;
}
}
if (land) break;
}
this.assertNotNull(land, "the nation has an empty coastal tile");
const spy = state._spawnUnit(land, 0, state.protoUnits.find((u) => u.id === "spy"));
const launch = state._spawnUnit(sea, 0, state.protoUnits.find((u) => u.id === "launch"));
screen.onState(state.snapshot(0));
await screen.map.onMoveOrdered([spy.id], { x: sea.x, y: sea.y });
const order = network.orders.find((entry) => entry.type === "embark");
this.assertNotNull(order, "the spy boards");
this.assertEqual(order.launch, launch.id);
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='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
// 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.$("#intelligence-propaganda-view 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.$("#intelligence-propaganda-view 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='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
const state = smallState();
state.budgets.set(0, 1.0e15);
state.requestCampaign(0, 0, "government", 1, 1);
screen.onState(state.snapshot(0));
const $slider = env.$("#intelligence-propaganda-view .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);
env.$("#tile-manage").click();
const options = env.$("#tile-manage-improvements .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 card shows the work's icon and name; the resource bill and the
// estimated price both live in the hover tooltip.
const roadCard = options.eq(0).closest(".building-tile");
this.assertTrue(
roadCard.find("img.building-icon").attr("src").endsWith("icon_road.svg"),
"the road card shows its icon"
);
this.assertTrue(roadCard.find(".bt-name").text() === "Road", "the card names the work");
options.eq(0).trigger("mouseenter");
const tipCost = env.$(".card-tooltip .tip-cost").text();
this.assertTrue(tipCost.includes("50\u00a0000 t"), `the steel cost is shown: ${tipCost}`);
this.assertTrue(tipCost.includes("100 GWh"), `the energy cost is shown: ${tipCost}`);
this.assertEqual(
env.$(".card-tooltip .tip-cost img").length,
2,
"steel and energy carry resource icons"
);
// The money figure is the build cost plus the material bill at today's
// market prices, not the raw build cost. It lives in the tooltip, so the
// button's meta stays a compact resource bill.
const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
const expectedCost = quotedBuildCost(
roadProto.buildCost,
tileImprovementResourceCost(roadProto),
marketPricesFrom(screen.snapshot)
);
this.assertTrue(
tipCost.includes(groupDigits(Math.round(expectedCost))),
`the market price is shown in the tooltip: ${tipCost}`
);
options.eq(0).trigger("mouseleave");
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] });
env.$("#tile-manage").click();
const again = env.$("#tile-manage-improvements .transport-btn");
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-manage-improvements .improvement-btn.remove:not(:disabled)");
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);
env.$("#tile-manage").click();
const labels = env.$("#tile-manage-improvements .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-manage-improvements .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);
env.$("#tile-manage").click();
this.assertFalse(
env.$("#tile-manage-improvements .transport-btn").eq(0).prop("disabled"),
"transport stays clickable in debt"
);
const military = env.$("#tile-manage-improvements .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);
env.$("#tile-manage").click();
// Every improvement button is gone while the work runs.
this.assertSize(env.$("#tile-manage-improvements .improvement-group"), 0, "nothing is left");
this.assertSize(env.$("#tile-manage-improvements .tile-btn"), 0, "no military or economic works");
this.assertSize(env.$("#tile-manage-improvements .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("raised to meet them"), `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);
env.$("#tile-manage").click();
const quotedTip = () => {
const $btn = env.$("#tile-manage-improvements .transport-btn").eq(0);
$btn.trigger("mouseenter");
const text = env.$(".card-tooltip .tip-cost").text();
$btn.trigger("mouseleave");
return text;
};
const before = quotedTip();
// 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 = quotedTip();
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
// The steel amount itself is unchanged, in the tooltip.
env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseenter");
this.assertTrue(
env.$(".card-tooltip .tip-cost").text().includes("50\u00a0000 t"),
"the steel amount is unchanged"
);
env.$("#tile-manage-improvements .transport-btn").eq(0).trigger("mouseleave");
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_card_toggle_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]);
const $cards = env.$("#unit-stack-list .stack-unit-tile");
this.assertSize($cards, 2, "each stacked unit is a card");
$cards.eq(0).trigger("click");
this.assertEqual(screen.selectedUnitIds.length, 1, "toggling a card off narrows the selection");
this.assertEqual(screen.selectedUnitIds[0], second.id);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_stack_panel_groups_units_by_type() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const infantry = state.units.find((u) => u.civ === 0);
const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]);
const artilleryProto = state.protoUnits.find((proto) => proto.id === "artillery");
const artillery = state._spawnUnit(infantry.coords, 0, artilleryProto);
screen.onState(state.snapshot(0));
screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]);
const $rows = env.$("#unit-stack-list .stack-type-row");
this.assertSize($rows, 2, "one line per unit type");
this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "each unit is a card");
this.assertEqual($rows.eq(0).find(".stack-type-count").text(), "×2", "the infantry line counts two");
this.assertSize($rows.eq(0).find(".stack-unit-tile"), 2, "both infantry cards sit on that line");
this.assertSize(
env.$("#unit-stack-list .stack-type-name"),
0,
"the type name lives in the icon tooltip, not the line"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_group_checkbox_selects_and_excludes_a_type() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const infantry = state.units.find((u) => u.civ === 0);
const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(
infantry.coords,
0,
state.protoUnits.find((proto) => proto.id === "artillery")
);
screen.onState(state.snapshot(0));
screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]);
const row = (index) => env.$("#unit-stack-list .stack-type-row").eq(index);
this.assertEqual(row(0).find(".stack-group-check").text(), "✓", "the infantry group reads all-ticked");
row(0).find(".stack-group-check").trigger("click");
this.assertEqual(screen.selectedUnitIds.length, 1, "excluding a type leaves the other selected");
this.assertEqual(screen.selectedUnitIds[0], artillery.id);
this.assertEqual(row(0).find(".stack-group-check").text(), "", "the excluded group is unticked");
this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "its cards stay in the list");
row(0).find(".stack-group-check").trigger("click");
this.assertEqual(screen.selectedUnitIds.length, 3, "re-ticking the group selects every unit");
this.assertEqual(row(0).find(".stack-group-check").text(), "✓");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_an_unticked_card_stays_and_sorts_to_the_back() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const first = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(first.coords, 0, state.protoUnits[0]);
const third = state._spawnUnit(first.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen._selectUnits([first.id, second.id, third.id]);
let $cards = env.$("#unit-stack-list .stack-unit-tile");
this.assertSize($cards, 3, "all three infantry are cards");
const untickedId = Number($cards.eq(0).attr("data-id"));
$cards.eq(0).trigger("click");
$cards = env.$("#unit-stack-list .stack-unit-tile");
this.assertSize($cards, 3, "the unticked card is not dropped");
this.assertEqual(
Number($cards.eq(2).attr("data-id")),
untickedId,
"the unticked card moves to the back of its list"
);
this.assertTrue(
screen.selectedUnitIds.indexOf(untickedId) === -1,
"the unticked unit leaves the selection"
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_choosing_a_target_fires_and_an_empty_tile_cancels() {
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 artillery = state._spawnUnit(
home.coords,
0,
state.protoUnits.find((unit) => unit.id === "artillery")
);
const goal = adjacentLand(state, artillery);
state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
let fired = null;
screen._orderMove = (units, coords, type) => { fired = { units, coords, type }; };
screen._selectUnits([artillery.id]);
screen._toggleTargeting();
screen._chooseTarget({ x: goal.x, y: goal.y });
this.assertNull(screen._targeting, "picking a target leaves target mode");
this.assertNotNull(fired, "the strike is issued");
this.assertEqual(fired.type, "attack");
fired = null;
screen._toggleTargeting();
screen._chooseTarget({ x: home.coords.x, y: home.coords.y });
this.assertNull(screen._targeting, "a friendly tile still leaves target mode");
this.assertNull(fired, "but no order is sent");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_attack_action_enters_targeting_for_a_ranged_stack() {
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 artillery = state._spawnUnit(
home.coords,
0,
state.protoUnits.find((unit) => unit.id === "artillery")
);
const goal = adjacentLand(state, artillery);
state._spawnUnit(goal, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen._selectUnits([artillery.id]);
this.assertFalse(
env.$("#unit-attack-order").hasClass("hidden"),
"a single ranged type offers the Attack action"
);
env.$("#unit-attack-order").trigger("click");
this.assertTrue(env.$("#unit-attack-order").hasClass("active"), "picking a target lights the button");
this.assertNotNull(screen.map._targeting, "the map entered target mode");
this.assertEqual(screen.map._targeting.origin.x, artillery.coords.x, "the reach starts at the battery");
this.assertEqual(screen.map._targeting.origin.y, artillery.coords.y);
const svg = screen.map.$targeting.find("svg.targeting-arcs");
this.assertEqual(svg.length, 1, "the reach overlay is the styled targeting-arcs svg");
const boundary = svg.find("path.range-boundary");
this.assertEqual(boundary.length, 1, "the reach boundary is drawn");
this.assertTrue((boundary.attr("d") || "").length > 0, "the boundary is a solid red path, not empty");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_missile_can_be_armed_with_a_nuclear_warhead() {
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);
const missileProto = state.protoUnits.find((p) => p.id === "missile");
const missile = state._spawnUnit(capital.coords, 0, missileProto);
const targetCoords = landAtDistance(state, capital.coords, 1);
state._spawnUnit(targetCoords, 1, state.protoUnits[0]);
screen.onState(state.snapshot(0));
const data = screen._unit(missile.id);
screen._selectUnits([missile.id]);
this.assertTrue(env.$("#unit-nuclear").hasClass("hidden"), "no warhead without the bomb");
// Research nuclear weapons and take a fresh snapshot.
const techIndex = state.technologies.findIndex((t) => t.id === "nuclear_weapons");
state.researched.get(0).add(techIndex);
screen.onState(state.snapshot(0));
this.assertFalse(env.$("#unit-nuclear").hasClass("hidden"), "the warhead toggle shows");
env.$("#unit-nuclear").click();
this.assertTrue(screen._nuclearPayload, "the toggle arms the next strike");
screen._sendMove([data], targetCoords, null, "attack", null);
const order = screen.network.orders.at(-1);
this.assertEqual(order.type, "attack");
this.assertTrue(order.nuclear, "the attack carries the nuclear payload");
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;
}