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