diff --git a/client/css/style.css b/client/css/style.css
index fa58c05..2fcd66b 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -429,6 +429,7 @@ input:focus, select:focus { border-color: var(--accent); }
}
.info-section.hidden { display: none !important; }
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
+.tile-gdp-mod { font-size: 12px; color: var(--muted); padding-left: 12px; font-variant-numeric: tabular-nums; }
#bottom-panel .row { justify-content: flex-start; margin-top: 10px; }
.health-row { display: flex; align-items: center; gap: 8px; }
.health-bar { width: 220px; height: 12px; background: rgba(30, 0, 0, 0.8); border-radius: 6px; overflow: hidden; margin-top: 6px; }
diff --git a/client/index.html b/client/index.html
index 649389b..efb4ea2 100644
--- a/client/index.html
+++ b/client/index.html
@@ -129,6 +129,7 @@
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 1233c59..11a66a3 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -27,6 +27,8 @@ export class GameScreen {
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
+ this._tileGdpPenalties = new Map();
+ this._tileBattleGdpDeficits = new Map();
this._territoryOwner = new Map();
this._battles = new Set();
this._researched = new Set();
@@ -112,6 +114,12 @@ export class GameScreen {
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
);
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
+ this._tileGdpPenalties = new Map(
+ (state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
+ );
+ this._tileBattleGdpDeficits = new Map(
+ (state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
+ );
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
this._cityBuildings = new Map();
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index fb38515..976d7be 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -12,9 +12,10 @@ import {
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
+ ECONOMY,
pillageSteps,
} from "../../../shared/data.js";
-import { defenseFactors, unitDefense } from "../../../shared/rules.js";
+import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
@@ -156,8 +157,10 @@ export const panelMethods = {
const speed = units.length > 1 ? this._groupSpeed(units) : null;
const targets = [];
const routes = new Map();
+ // Same-type units in one stack share a route, so search once per type.
+ const cache = new Map();
for (const unit of units) {
- const route = await this.map.planMove(unit, coords, speed);
+ const route = await this.map.planMove(unit, coords, speed, cache);
if (!route || route.length < 2) continue;
routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue;
@@ -365,12 +368,74 @@ export const panelMethods = {
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
+ this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
$("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
},
+ // The base GDP per capita of a claimable tile plus every factor that shapes
+ // it, mirroring the server's getTileGdpPerCapita through the shared helper.
+ _tileGdpBreakdown(coords, owner, city, tile) {
+ const k = `${coords.x},${coords.y}`;
+ const factors = gdpPerCapitaFactors(tile, {
+ cityKind: city ? (city.isCapital ? "capital" : "city") : null,
+ nextToCity: !city && this._isNextToCity(coords, owner),
+ pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
+ battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
+ });
+ return {
+ base: ECONOMY.baseGdpPerCapita,
+ factors,
+ total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
+ };
+ },
+
+ _isNextToCity(coords, owner) {
+ for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
+ const found = (this.snapshot.cities || []).some(
+ (city) =>
+ city.civ === owner &&
+ city.coords[0] === neighbour.x &&
+ city.coords[1] === neighbour.y
+ );
+ if (found) return true;
+ }
+ return false;
+ },
+
+ // Lists the tile's GDP per capita and each factor behind it, so a click
+ // explains exactly why the figure is what it is.
+ _renderTileGdp(breakdown) {
+ const $container = $("#tile-gdp").empty();
+ if (!breakdown) {
+ $container.text("GDP/capita: —");
+ return;
+ }
+ $container.append(
+ $("
").text(`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`)
+ );
+ $container.append(
+ $("
").text(`Base €${groupDigits(breakdown.base)}`)
+ );
+ for (const factor of breakdown.factors) {
+ // A modifier that changes nothing (×1 or ±€0) is left out.
+ if (factor.amount !== undefined) {
+ if (factor.amount === 0) continue;
+ } else if (factor.multiplier === 1) {
+ continue;
+ }
+ const value =
+ factor.amount !== undefined
+ ? `−€${groupDigits(Math.round(Math.abs(factor.amount)))}`
+ : `×${factor.multiplier.toFixed(2)}`;
+ $container.append(
+ $("
").text(`${factor.label} ${value}`)
+ );
+ }
+ },
+
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js
index 57b690c..6a92e0c 100644
--- a/client/js/map_view/motion.js
+++ b/client/js/map_view/motion.js
@@ -176,8 +176,8 @@ export const motionMethods = {
// next snapshot confirms or corrects the route. `planMove` computes the route
// without touching the view, so callers can inspect it (for example to warn
// before entering foreign territory) before committing to the prediction.
- planMove(unitData, goal, speed = null) {
- return this._planMove(unitData, goal, speed).then((plan) => (plan ? plan.route : null));
+ planMove(unitData, goal, speed = null, cache = null) {
+ return this._planMove(unitData, goal, speed, cache).then((plan) => (plan ? plan.route : null));
},
predictMove(unitData, goal, speed = null, route = null) {
@@ -221,19 +221,29 @@ export const motionMethods = {
this.animate(0);
},
- async _planMove(unitData, goal, speed = null) {
+ async _planMove(unitData, goal, speed = null, cache = null) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
if (start.x === goal.x && start.y === goal.y) return null;
+ // Units of the same type marching from the same tile to the same goal follow
+ // the same route, so the search (and its per-segment hours) runs once.
+ const cacheKey = cache
+ ? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}`
+ : null;
+ if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
const path = await this._search(unitData, proto, start, goal, speed);
- if (!path || path.length < 2) return null;
- const segmentHours = [];
- for (let i = 1; i < path.length; i++) {
- segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
+ let plan = null;
+ if (path && path.length >= 2) {
+ const segmentHours = [];
+ for (let i = 1; i < path.length; i++) {
+ segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
+ }
+ plan = { route: path, segmentHours, start };
}
- return { route: path, segmentHours, start };
+ if (cacheKey) cache.set(cacheKey, plan);
+ return plan;
},
// The A* this mirrors recomputes the cheapest movement cost on every node,
diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js
index 140e260..fbe438f 100644
--- a/client/js/modals/nation.js
+++ b/client/js/modals/nation.js
@@ -57,6 +57,11 @@ export class NationModal {
this.technologies = technologies;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
+ // Rebuild the rows from scratch: reopening the panel must not stack a
+ // second set of buttons on top of the previous one, or the stale rows keep
+ // their old labels and disabled state and appear unresponsive.
+ this.$governmentList.empty();
+ this.$technologyList.empty();
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts);
diff --git a/shared/data/economy.js b/shared/data/economy.js
index 68deb8c..5033ea1 100644
--- a/shared/data/economy.js
+++ b/shared/data/economy.js
@@ -10,7 +10,7 @@ export const ECONOMY = {
citySurroundingGdpBonus: 0.3,
gdpPerProductionPoint: 100_000,
budgetGdpRate: 0.001,
- startingBudget: 0,
+ startingBudget: 1_000_000_000,
basePopulationGrowthRate: 0.005,
productionCapacity(gdp) {
diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js
index f2d5075..e6ec456 100644
--- a/shared/game_state/economy.js
+++ b/shared/game_state/economy.js
@@ -11,6 +11,8 @@ import {
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import {
buildingUpkeep,
+ gdpPerCapitaFactors,
+ gdpPerCapitaFromFactors,
governmentEffects,
prerequisitesMet,
technologyEffects,
@@ -61,20 +63,15 @@ export const economyMethods = {
if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k);
const owner = this.civAt(coords);
if (owner < 0) return 0;
- let perCapita = ECONOMY.baseGdpPerCapita * this.terrainStats.gdpMultiplier(coords);
const city = this.cityAt(coords);
- if (city) {
- perCapita *= city.isCapital
- ? ECONOMY.capitalTileGdpMultiplier
- : ECONOMY.cityTileGdpMultiplier;
- } else if (this._isNextToCity(coords, owner)) {
- perCapita *= 1 + ECONOMY.citySurroundingGdpBonus;
- }
- // Pillaging leaves a lasting mark on the tile's productivity.
- perCapita *= this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1;
- // Fighting scars the tile too, but that loss recovers over time.
- perCapita -= this.tileBattleGdpDeficit.get(k) || 0;
- if (perCapita < 0) perCapita = 0;
+ const factors = gdpPerCapitaFactors(this.tiles[k], {
+ cityKind: city ? (city.isCapital ? "capital" : "city") : null,
+ nextToCity: !city && this._isNextToCity(coords, owner),
+ pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
+ // Fighting scars the tile too, but that loss recovers over time.
+ battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
+ });
+ const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors);
this._gdpPerCapitaCache.set(k, perCapita);
return perCapita;
},
diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js
index 61922a2..29a07ba 100644
--- a/shared/game_state/movement.js
+++ b/shared/game_state/movement.js
@@ -22,12 +22,15 @@ export const movementMethods = {
const speed = units.length > 1
? Math.min(...units.map((unit) => this._unitSpeed(unit)))
: null;
+ // Units of the same type starting from the same tile share a route, so the
+ // search runs once per type instead of once per unit in the stack.
+ const pathCache = new Map();
let moved = 0;
for (const unit of units) {
// Only a unit that actually receives the new route drops its scheduled
// stops; a unit whose route cannot be found keeps its existing movement
// rather than being silently emptied out.
- if (this._orderMove(unit, goal, speed)) {
+ if (this._orderMove(unit, goal, speed, pathCache)) {
unit.waypoints = [];
moved += 1;
}
@@ -65,16 +68,17 @@ export const movementMethods = {
const speed = units.length > 1
? Math.min(...units.map((unit) => this._unitSpeed(unit)))
: null;
+ const pathCache = new Map();
let moved = 0;
for (const unit of units) {
if (!Array.isArray(unit.waypoints)) unit.waypoints = [];
if (append) {
for (const goal of destinations) unit.waypoints.push({ ...goal });
- if (this._hasRoute(unit) || this._followNextWaypoint(unit, speed)) moved += 1;
+ if (this._hasRoute(unit) || this._followNextWaypoint(unit, speed, pathCache)) moved += 1;
} else {
const previous = unit.waypoints;
unit.waypoints = destinations.slice(1).map((goal) => ({ ...goal }));
- if (this._orderMove(unit, destinations[0], speed)) moved += 1;
+ if (this._orderMove(unit, destinations[0], speed, pathCache)) moved += 1;
// An unroutable first leg leaves the unit exactly as it was.
else unit.waypoints = previous;
}
@@ -105,23 +109,23 @@ export const movementMethods = {
// Pops the next scheduled destination and routes to it, keeping the rest of
// the queue. Returns false when nothing is left or the next destination is
// unreachable (in which case the whole schedule is abandoned).
- _followNextWaypoint(unit, speed = null) {
+ _followNextWaypoint(unit, speed = null, pathCache = null) {
if (!unit.waypoints || unit.waypoints.length === 0) return false;
const next = unit.waypoints.shift();
const pace = speed !== null ? speed : unit.moveSpeed;
- if (!this._orderMove(unit, next, pace)) {
+ if (!this._orderMove(unit, next, pace, pathCache)) {
this._stopUnit(unit);
return false;
}
return true;
},
- _orderMove(unit, goal, speed) {
+ _orderMove(unit, goal, speed, pathCache = null) {
// Attacking: a hostile military unit standing on the goal. Combat itself is
// joined on arrival; this only decides whether the march declares war.
const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null;
const attacking = !!target && this._isMilitary(unit);
- const path = this.findPath(unit, unit.coords, goal);
+ const path = this.findPath(unit, unit.coords, goal, pathCache);
if (path.length < 2) {
return false;
}
@@ -322,12 +326,21 @@ export const movementMethods = {
return true;
},
- findPath(unit, start, goal) {
+ // `pathCache` (optional) shares routes between units whose civ, type, start
+ // and goal are identical: the route shape depends only on the unit type's
+ // traversable terrain and the owning civ's territory/fog, while movement
+ // speed merely scales every step cost by the same factor and so cannot change
+ // which route is cheapest. The path array is treated as read-only by callers.
+ findPath(unit, start, goal, pathCache = null) {
+ const cacheKey = pathCache
+ ? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}`
+ : null;
+ if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
const targetCiv = this._targetCivFor(unit, goal);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
- return this._pathfinder.findPath(
+ const path = this._pathfinder.findPath(
start,
goal,
(coords) => this._neighbours(coords),
@@ -347,6 +360,8 @@ export const movementMethods = {
return heuristicCache.get(k);
}
);
+ if (cacheKey) pathCache.set(cacheKey, path);
+ return path;
},
// The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index e24c5c2..20eb277 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -22,6 +22,10 @@ export const serializationMethods = {
units: this._serializeUnits(),
territory: this._serializedTerritory,
population: this._serializePopulation(),
+ // Sparse per-tile marks of war, so the browser can explain a tile's
+ // GDP per capita without shipping the whole breakdown for every tile.
+ tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty),
+ tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit),
civStats: this._serializeCivStats(),
budgets: this._serializeCivValues(this.budgets),
research: this._serializeCivValues(this.research),
@@ -125,6 +129,16 @@ export const serializationMethods = {
return result;
},
+ // A sparse [x, y, value] list for a per-tile map, used for the war marks.
+ _serializeTileValues(values) {
+ const result = [];
+ for (const [k, value] of values) {
+ const coords = parseKey(k);
+ result.push([coords.x, coords.y, value]);
+ }
+ return result;
+ },
+
// One entry per civilisation with its headline economy figures, so the
// diplomacy panel can show every nation's standing without a second lookup.
_serializeCivStats() {
diff --git a/shared/rules.js b/shared/rules.js
index 5b1eee6..56444d4 100644
--- a/shared/rules.js
+++ b/shared/rules.js
@@ -1,7 +1,7 @@
// Catalogue maths shared by the server (which charges costs) and the browser
// (which displays and disables buttons) so the numbers always agree.
-import { effectValue, CITY_DEFENSE_BONUS } from "./data.js";
+import { effectValue, CITY_DEFENSE_BONUS, ECONOMY } from "./data.js";
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP.
@@ -88,3 +88,45 @@ export function defenseMultiplier(tile, hasCity) {
export function unitDefense(baseDefense, tile, hasCity) {
return baseDefense * defenseMultiplier(tile, hasCity);
}
+
+// Every GDP-per-capita modifier of a tile, in display order. `tile` supplies the
+// terrain multiplier and its name; `cityKind` is "capital", "city" or null and
+// `nextToCity` is true when the tile borders one of its owner's cities. The
+// pillage penalty and battle deficit are the lasting marks of war. Shared by the
+// server (which derives each tile's GDP) and the browser (which explains it), so
+// the numbers always agree.
+export function gdpPerCapitaFactors(tile, {
+ cityKind = null,
+ nextToCity = false,
+ pillagePenalty = 1,
+ battleDeficit = 0,
+} = {}) {
+ const factors = [];
+ const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0;
+ factors.push({ label: tile ? tile.terrainType : "Unknown", multiplier: terrainMultiplier });
+ if (cityKind === "capital") {
+ factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileGdpMultiplier });
+ } else if (cityKind === "city") {
+ factors.push({ label: "City", multiplier: ECONOMY.cityTileGdpMultiplier });
+ } else if (nextToCity) {
+ factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingGdpBonus });
+ }
+ if (pillagePenalty !== 1) {
+ factors.push({ label: "Pillaging", multiplier: pillagePenalty });
+ }
+ if (battleDeficit > 0) {
+ factors.push({ label: "Battle damage", amount: -battleDeficit });
+ }
+ return factors;
+}
+
+// Applies the `gdpPerCapitaFactors` list to `base`, in order, never falling
+// below zero.
+export function gdpPerCapitaFromFactors(base, factors) {
+ let total = base;
+ for (const factor of factors) {
+ if (factor.amount !== undefined) total += factor.amount;
+ else total *= factor.multiplier;
+ }
+ return total > 0 ? total : 0;
+}
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index dfa4166..abfe0fc 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -217,6 +217,25 @@ export class GameScreenTest extends TestCase {
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);
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index d9db37b..0e24cac 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -306,6 +306,29 @@ export class MapViewTest extends TestCase {
}
}
+ async test_plan_move_reuses_a_shared_route() {
+ const state = defaultState();
+ const { env, map, snapshot } = await this.build(state);
+ try {
+ const unit = snapshot.units.find((u) => u.civ === 0);
+ const stateUnit = state.units.find((u) => u.id === unit.id);
+ const goal = adjacentLand(state, stateUnit);
+ const cache = new Map();
+ let searches = 0;
+ const original = map._search.bind(map);
+ map._search = (...args) => {
+ searches += 1;
+ return original(...args);
+ };
+ const first = await map.planMove(unit, goal, null, cache);
+ const second = await map.planMove(unit, goal, null, cache);
+ this.assertEqual(searches, 1, "the second plan reuses the cached search");
+ this.assertTrue(first === second, "the same route is handed back");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_pan_moves_the_camera() {
const state = defaultState();
const { env, map } = await this.build(state);
diff --git a/tests/modals_test.js b/tests/modals_test.js
index ced0b78..4e32a92 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -165,6 +165,29 @@ export class NationModalTest extends TestCase {
}
}
+ async test_reopening_rebuilds_rows_instead_of_stacking_them() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ let technology = null;
+ modal.onTechnology = (index) => {
+ technology = index;
+ };
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
+ modal.close();
+ // Reopen with enough research to afford the first technology.
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
+ this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments");
+ this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length, "no stacked technologies");
+ const button = env.$("#technology-list .row-card").first().find(".actions button");
+ this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget");
+ button.click();
+ this.assertEqual(technology, 0);
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_budget_tab_shows_income_and_upkeep_breakdown() {
const env = await setupDom();
try {
diff --git a/tests/rules_test.js b/tests/rules_test.js
index 1dda8ca..dce9eef 100644
--- a/tests/rules_test.js
+++ b/tests/rules_test.js
@@ -11,6 +11,8 @@ import {
defenseFactors,
defenseMultiplier,
unitDefense,
+ gdpPerCapitaFactors,
+ gdpPerCapitaFromFactors,
} from "../shared/rules.js";
import {
effectValue,
@@ -171,4 +173,41 @@ export class RulesTest extends TestCase {
this.assertApprox(bonusOf("Mountain"), 1.0);
this.assertApprox(bonusOf("Land"), 0);
}
+
+ test_gdp_per_capita_factors_name_the_terrain_and_settlement() {
+ const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
+ const base = gdpPerCapitaFactors(plains);
+ this.assertSize(base, 1);
+ this.assertEqual(base[0].label, "Land");
+ this.assertApprox(base[0].multiplier, 1);
+
+ const city = gdpPerCapitaFactors(plains, { cityKind: "city" });
+ this.assertSize(city, 2);
+ this.assertEqual(city[1].label, "City");
+ this.assertApprox(city[1].multiplier, 2);
+
+ const capital = gdpPerCapitaFactors(plains, { cityKind: "capital" });
+ this.assertApprox(capital[1].multiplier, 2.5);
+
+ const near = gdpPerCapitaFactors(plains, { nextToCity: true });
+ this.assertEqual(near[1].label, "Near city");
+ this.assertApprox(near[1].multiplier, 1.3);
+ }
+
+ test_gdp_per_capita_factors_include_the_marks_of_war() {
+ const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
+ const factors = gdpPerCapitaFactors(plains, { pillagePenalty: 0.7, battleDeficit: 500 });
+ this.assertEqual(factors[1].label, "Pillaging");
+ this.assertApprox(factors[1].multiplier, 0.7);
+ this.assertEqual(factors[2].label, "Battle damage");
+ this.assertApprox(factors[2].amount, -500);
+ }
+
+ test_gdp_per_capita_from_factors_applies_in_order_and_floors_at_zero() {
+ this.assertApprox(
+ gdpPerCapitaFromFactors(10000, [{ multiplier: 2 }, { amount: -500 }]),
+ 19500
+ );
+ this.assertEqual(gdpPerCapitaFromFactors(100, [{ amount: -500 }]), 0);
+ }
}
diff --git a/tests/stacking_test.js b/tests/stacking_test.js
index 9891bdf..2634d0f 100644
--- a/tests/stacking_test.js
+++ b/tests/stacking_test.js
@@ -30,6 +30,18 @@ function emptyLandPair(state) {
}
export class StackingTest extends TestCase {
+ // Counts actual A* runs by wrapping this state's pathfinder.
+ countPathSearches(state) {
+ const counter = { count: 0 };
+ const pathfinder = state._pathfinder;
+ const original = pathfinder.findPath.bind(pathfinder);
+ pathfinder.findPath = (...args) => {
+ counter.count += 1;
+ return original(...args);
+ };
+ return counter;
+ }
+
test_friendly_units_can_share_a_tile() {
const state = smallState();
const pair = emptyLandPair(state);
@@ -53,6 +65,33 @@ export class StackingTest extends TestCase {
this.assertEqual(state.unitsAt(pair.b).length, 2, "the stack stays together");
}
+ test_a_stack_of_one_type_pathfinds_once_and_shares_the_route() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ const ids = [];
+ for (let i = 0; i < 5; i++) ids.push(state._spawnUnit(pair.a, 0, state.protoUnits[0]).id);
+ const searches = this.countPathSearches(state);
+ this.assertTrue(state.requestGroupMove(ids, pair.b));
+ this.assertEqual(searches.count, 1, "one search covers the whole stack");
+ const first = state.findUnit(ids[0]).path;
+ for (const id of ids) {
+ this.assertTrue(state.findUnit(id).path === first, "the stack shares one route");
+ }
+ }
+
+ test_a_mixed_stack_pathfinds_once_per_unit_type() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ const ids = [
+ state._spawnUnit(pair.a, 0, state.protoUnits[0]).id,
+ state._spawnUnit(pair.a, 0, state.protoUnits[0]).id,
+ state._spawnUnit(pair.a, 0, state.protoUnits[2]).id,
+ ];
+ const searches = this.countPathSearches(state);
+ this.assertTrue(state.requestGroupMove(ids, pair.b));
+ this.assertEqual(searches.count, 2, "one search per type, not per unit");
+ }
+
test_a_lone_unit_keeps_its_own_speed() {
const state = smallState();
const pair = emptyLandPair(state);