Improved tile information, optimized stack pathfinding
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user