Files
Battle-for-Tismo/tests/game_screen_test.js
T

1198 lines
44 KiB
JavaScript
Raw 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 } from "../shared/data.js";
import { smallState, adjacentLand } from "./framework/helpers.js";
function stubNetwork() {
return {
isAdmin: false,
localPlayerId: 2,
players: { 2: { id: 2, name: "Carol", admin: false } },
orders: [],
setup: null,
sendOrder(order) {
this.orders.push(order);
},
sendGameSetup(setup) {
this.setup = setup;
},
};
}
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_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();
screen.onState(state.snapshot(0));
this.assertEqual(env.$("#civ-name").text(), "France");
this.assertEqual(env.$("#player-name").text(), "Carol");
this.assertTrue(env.$("#stat-pop").text() !== "0");
this.assertTrue(env.$("#stat-date").text().includes("2000"));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
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"), 2, "infantry and artillery 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.sendSetup();
this.assertEqual(network.setup.player_civ, 0);
this.assertEqual(network.setup.civilisations, ["france", "britain"]);
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"), 5);
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("Tax income"), "region income 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);
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.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_unaffordable_air_strike_is_refused_with_a_message() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
state.budgets.set(0, 0);
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]);
screen.onState(state.snapshot(0));
await screen._orderMove([screen._unit(jet.id)], goal, "attack");
this.assertEqual(network.orders.length, 0, "no order is sent without the budget");
this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget");
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();
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();
const before = env.$("#city-train-list button").length;
this.assertEqual(before, 2, "only infantry and artillery 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;
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-train-list .free-button"), 2, "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();
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();
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"));
this.assertTrue(env.$("#tile-gdp").text().includes("GDP/capita"));
this.assertTrue(
env.$("#tile-gdp").text().includes("Capital"),
"the tile panel explains the capital GDP bonus"
);
screen._renderTileGdp({
base: 10000,
factors: [
{ label: "Terrain", multiplier: 1 },
{ label: "City", multiplier: 2 },
{ label: "Battle damage", amount: 0 },
],
total: 20000,
});
const gdpText = env.$("#tile-gdp").text();
this.assertFalse(gdpText.includes("Terrain"), "a no-op multiplier is hidden");
this.assertFalse(gdpText.includes("Battle damage"), "a zero modifier is hidden");
this.assertTrue(gdpText.includes("City"), "a real modifier is still shown");
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_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.research.set(0, 1.0e6);
state.budgets.set(0, 1.0e15);
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
state.requestResearchTechnology(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_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("Britain"));
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").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_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 Britain"));
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 Britain"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_war_declared_on_the_player_is_an_alert() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
state.requestDeclareWar(1, 0);
screen.onState(state.snapshot(0));
const alert = env.$("#game-feed .feed-line.feed-alert");
this.assertSize(alert, 1);
this.assertTrue(alert.text().includes("declared war on you"));
screen.leave();
this.assertSize(env.$("#game-feed .feed-line"), 0, "leaving clears the feed");
} finally {
teardownDom(env);
}
}
async test_stack_menu_selects_all_and_splits_one() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
this.assertFalse(env.$("#stack-menu").hasClass("hidden"));
this.assertSize(env.$("#stack-menu .stack-item"), 3, "two units plus select all");
env.$("#stack-menu .stack-all").click();
this.assertEqual(screen.selectedUnitIds, [unit.id, second.id]);
this.assertTrue(env.$("#unit-name").text().includes("2 units selected"));
this.assertTrue(env.$("#stack-menu").hasClass("hidden"), "the menu closes on pick");
screen.map.onStackMenu(unit.coords, [unit.id, second.id], 100, 120);
env.$("#stack-menu .stack-item").not(".stack-move").not(".stack-all").first().click();
this.assertEqual(screen.selectedUnitIds, [unit.id], "picking one splits the stack");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_clicking_a_stacked_unit_selects_the_whole_stack() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0));
screen.map.onUnitSelected(unit.id);
this.assertEqual(
screen.selectedUnitIds.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b),
"the whole stack is selected"
);
// The next order is given to every unit in the stack.
const goal = adjacentLand(state, unit);
await screen.map._onRightClick(goal, {});
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(
network.orders[0].units.slice().sort((a, b) => a - b),
[unit.id, second.id].sort((a, b) => a - b)
);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_group_move_uses_the_slowest_speed() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const capital = state.units.find((u) => u.civ === 0);
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
screen.onState(state.snapshot(0));
screen._selectUnits([infantry.id, artillery.id]);
const goal = adjacentLand(state, capital);
await screen.map.onMoveOrdered([infantry.id, artillery.id], goal);
this.assertEqual(network.orders[0].type, "move");
this.assertEqual(network.orders[0].units, [infantry.id, artillery.id]);
const infHours = screen.map._unitMotion.get(infantry.id).segmentHours[0];
const artHours = screen.map._unitMotion.get(artillery.id).segmentHours[0];
this.assertApprox(infHours, artHours, 1e-9, "both move at the slowest member's pace");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_forecasts_and_sends_the_order() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
const coords = findEnemyTile(state);
this.assertNotNull(coords, "found an enemy tile");
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
const first = state._spawnUnit(coords, 0, state.protoUnits[0]);
const second = state._spawnUnit(coords, 0, state.protoUnits[0]);
first.hp = 100;
second.hp = 90;
screen.onState(state.snapshot(0));
screen._selectUnits([first.id, second.id]);
const $button = env.$("#unit-pillage");
this.assertFalse($button.hasClass("hidden"), "the pillage button shows");
const tooltip = $button.attr("data-tooltip") || "";
this.assertTrue(tooltip.includes("19 000"), `forecast removes 19k: ${tooltip}`);
this.assertTrue(tooltip.includes("0.49"), "forecast shows the GDP multiplier");
this.assertTrue(tooltip.includes("+10") && tooltip.includes("+9"), "forecast shows both heals");
$button.click();
this.assertEqual(network.orders[0], { type: "pillage", units: [first.id, second.id] });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_pillage_button_hides_in_friendly_land() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
screen.onState(state.snapshot(0));
screen._selectUnits([unit.id]);
this.assertTrue(env.$("#unit-pillage").hasClass("hidden"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_tab_sends_campaign_and_policy_orders() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
this.assertTrue(env.$("#tab-politics").hasClass("active"));
this.assertGreater(env.$("#politics-list .politics-card").length, 0);
const $start = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertSize($start, 1);
$start.trigger("click");
const campaign = network.orders.find((order) => order.type === "campaign");
this.assertNotNull(campaign);
this.assertEqual(campaign.observer, 0);
this.assertEqual(campaign.targetKind, "government");
this.assertEqual(campaign.target, 1);
this.assertTrue(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
const $new = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "New minority policy"
);
this.assertSize($new, 1);
$new.trigger("click");
this.assertFalse(env.$("#tab-politics .politics-wizard").hasClass("hidden"));
const $enactNow = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Enact policy"
);
this.assertSize($enactNow, 1);
$enactNow.trigger("click");
const policy = network.orders.find((order) => order.type === "policy");
this.assertNotNull(policy);
this.assertEqual(policy.policy, "champion");
this.assertEqual(policy.ethnicity, 1);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_tab_survives_snapshot_refresh() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
env
.$("#tab-politics button")
.filter((index, element) => env.$(element).text() === "New minority policy")
.trigger("click");
const $selects = env.$("#tab-politics .politics-wizard select");
$selects.eq(0).val("expel").trigger("change");
$selects.eq(1).val("1").trigger("change");
// A snapshot arrives while the panel is open: the wizard and its choices
// must not be torn down.
screen.onState(state.snapshot(0));
this.assertFalse(
env.$("#tab-politics .politics-wizard").hasClass("hidden"),
"the wizard stays open"
);
this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(0).val(), "expel");
this.assertEqual(env.$("#tab-politics .politics-wizard select").eq(1).val(), "1");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_cancel_campaign_and_duplicate_guard() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
// Inject a running campaign owned by the player.
const state = smallState();
state.budgets.set(0, 1.0e15);
state.requestCampaign(0, 0, "government", 1, 1);
screen.onState(state.snapshot(0));
const $cancel = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Cancel campaign"
);
this.assertSize($cancel, 1);
$cancel.trigger("click");
this.assertEqual(network.orders[network.orders.length - 1].type, "cancel_campaign");
// The launcher now refuses to duplicate the running campaign.
const $start = env.$("#tab-politics button").filter(
(index, element) => env.$(element).text() === "Start campaign"
);
this.assertTrue($start.prop("disabled"), "duplicate campaign blocked");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_campaign_spending_controls() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
const state = smallState();
state.budgets.set(0, 1.0e15);
state.requestCampaign(0, 0, "government", 1, 1);
screen.onState(state.snapshot(0));
const $slider = env.$("#tab-politics .politics-slider");
this.assertSize($slider, 1);
$slider.val(20000).trigger("change");
const spending = network.orders.find((order) => order.type === "campaign_spending");
this.assertNotNull(spending);
this.assertEqual(spending.amount, 20000);
// Pushing it to zero asks first, and only stops on confirmation.
network.orders.length = 0;
$slider.val(0).trigger("change");
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "confirmation asked");
this.assertNull(
network.orders.find((order) => order.type === "cancel_campaign"),
"nothing sent before confirming"
);
env.$("#confirm-ok").click();
this.assertNotNull(network.orders.find((order) => order.type === "cancel_campaign"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_opinion_breakdown() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='politics']").click();
this.assertGreater(env.$("#tab-politics .opinion-row").length, 0);
const headings = env.$("#tab-politics .opinion-heading");
this.assertEqual(headings.eq(0).text(), "Governments");
this.assertEqual(headings.eq(1).text(), "Peoples");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_tile_detail_shows_ethnic_makeup() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
const k = Array.from(state.tilePopulation.keys()).find((key) => {
const parts = key.split(",").map(Number);
return state._isLand({ x: parts[0], y: parts[1] });
});
const parts = k.split(",").map(Number);
screen._showTile({ x: parts[0], y: parts[1] });
this.assertGreater(env.$("#tile-ethnicity .ethnic-legend-row").length, 0);
this.assertTrue(env.$("#tile-ethnicity .ethnic-legend-row").first().text().includes("("));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_city_people_tab_shows_ethnic_makeup() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
screen.onState(state.snapshot(0));
const capital = state.cities.find((city) => city.civ === 0 && city.isCapital);
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
env.$("#modal-city .tab[data-tab='people']").click();
this.assertTrue(env.$("#city-tab-people").hasClass("active"));
this.assertGreater(env.$("#city-people-list .ethnic-chart").length, 0);
this.assertGreater(env.$("#city-people-list .ethnic-legend-row").length, 0);
screen.leave();
} finally {
teardownDom(env);
}
}
}
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;
}