Improved tile information, optimized stack pathfinding

This commit is contained in:
2026-09-18 05:56:54 +02:00
parent b9527e004b
commit da1d443807
16 changed files with 335 additions and 34 deletions
+1
View File
@@ -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; }
+1
View File
@@ -129,6 +129,7 @@
<div id="tile-owner"></div>
<div id="tile-terrain"></div>
<div id="tile-population"></div>
<div id="tile-gdp"></div>
<div id="tile-improvements"></div>
</div>
<div id="city-info" class="info-section hidden">
+8
View File
@@ -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();
+67 -2
View File
@@ -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(
$("<div></div>").text(`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`)
);
$container.append(
$("<div class='tile-gdp-mod'></div>").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(
$("<div class='tile-gdp-mod'></div>").text(`${factor.label} ${value}`)
);
}
},
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
+18 -8
View File
@@ -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,
+5
View File
@@ -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);
+1 -1
View File
@@ -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) {
+10 -13
View File
@@ -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;
},
+24 -9
View File
@@ -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
+14
View File
@@ -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() {
+43 -1
View File
@@ -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;
}
+19
View File
@@ -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);
+23
View File
@@ -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);
+23
View File
@@ -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 {
+39
View File
@@ -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);
}
}
+39
View File
@@ -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);