diff --git a/client/assets/icon_coastal_cannon.svg b/client/assets/icon_coastal_cannon.svg
new file mode 100644
index 0000000..eeeec35
--- /dev/null
+++ b/client/assets/icon_coastal_cannon.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/assets/icon_fortifications.svg b/client/assets/icon_fortifications.svg
new file mode 100644
index 0000000..7ae0bd5
--- /dev/null
+++ b/client/assets/icon_fortifications.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_launch.svg b/client/assets/icon_launch.svg
new file mode 100644
index 0000000..4f1c159
--- /dev/null
+++ b/client/assets/icon_launch.svg
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/client/assets/icon_radar.svg b/client/assets/icon_radar.svg
new file mode 100644
index 0000000..0a3eecc
--- /dev/null
+++ b/client/assets/icon_radar.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/assets/icon_radar_tower.svg b/client/assets/icon_radar_tower.svg
new file mode 100644
index 0000000..b3c975d
--- /dev/null
+++ b/client/assets/icon_radar_tower.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_railway.svg b/client/assets/icon_railway.svg
new file mode 100644
index 0000000..a592840
--- /dev/null
+++ b/client/assets/icon_railway.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_road.svg b/client/assets/icon_road.svg
new file mode 100644
index 0000000..92eb2a7
--- /dev/null
+++ b/client/assets/icon_road.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/css/style.css b/client/css/style.css
index ab90f41..26adc70 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -295,17 +295,35 @@ input:focus, select:focus { border-color: var(--accent); }
image-rendering: pixelated;
}
-/* A ranged unit's bombardment: a red dashed line from the battery to the
- target and a red ring around the target. Drawn on the layer below the entity
- icons so the units themselves stay on top. */
-.target-line {
+/* A ranged unit's bombardment: a red dashed parabolic arc from the battery to
+ the target and a red ring around the target. Drawn on the layer below the
+ entity icons so the units themselves stay on top. */
+.target-arcs {
position: absolute;
- height: 0;
- border-top: 3px dashed #ff2b2b;
- transform-origin: 0 50%;
+ left: 0;
+ top: 0;
+ width: 1px;
+ height: 1px;
+ overflow: visible;
pointer-events: none;
+}
+.target-line {
+ fill: none;
+ stroke: #ff2b2b;
+ stroke-width: 3;
+ stroke-dasharray: 10 8;
+ stroke-linecap: round;
+ pointer-events: none;
+ opacity: 0.6;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
}
+.target-shell {
+ fill: #ffdd66;
+ stroke: #ff2b2b;
+ stroke-width: 1;
+ pointer-events: none;
+ filter: drop-shadow(0 0 3px rgba(255, 90, 40, 0.9));
+}
.target-ring {
position: absolute;
width: 46px;
@@ -316,8 +334,11 @@ input:focus, select:focus { border-color: var(--accent); }
border: 3px solid #ff2b2b;
border-radius: 50%;
pointer-events: none;
+ opacity: 0.6;
box-shadow: 0 0 6px rgba(255, 43, 43, 0.85);
}
+/* A barrage the player is looking at stands out from the rest. */
+.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
/* Outlines the selected or inspected tile. The polygon is built in JS from the
same warped hexagon as the terrain, so it follows the geography. */
@@ -440,6 +461,33 @@ input:focus, select:focus { border-color: var(--accent); }
/* On a battlefield both stacks shrink to half size and face each other across
the battle icon. */
.unit.in-battle { transform: scale(0.5); }
+/* A trench: a hatched overlay marking the dug-in tile. */
+.trench-marker {
+ position: absolute;
+ width: 34px;
+ height: 30px;
+ margin-left: -17px;
+ margin-top: -15px;
+ background-image: repeating-linear-gradient(
+ 45deg, rgba(90, 60, 30, 0.55) 0 3px, rgba(40, 26, 12, 0.55) 3px 6px
+ );
+ border: 1px solid rgba(0, 0, 0, 0.4);
+ pointer-events: none;
+ z-index: 3;
+}
+/* A military tile improvement marker. */
+.tile-improvement-marker {
+ position: absolute;
+ width: 22px;
+ height: 22px;
+ margin-left: -11px;
+ margin-top: -11px;
+ pointer-events: none;
+ z-index: 3;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
+}
+.tile-improvement-marker img { width: 100%; height: 100%; }
+
.battle-marker {
position: absolute;
width: 22px;
@@ -519,6 +567,43 @@ input:focus, select:focus { border-color: var(--accent); }
color: #ffd54a;
margin-right: 3px;
}
+/* A discreet progress bar under the city name, showing the first queued build
+ (yellow) or unit (blue). Hidden while the city is idle. */
+.city-progress {
+ position: absolute;
+ left: 50%;
+ bottom: 100%;
+ margin-bottom: 1px;
+ transform: translateX(-50%);
+ width: var(--city-size);
+ height: 3px;
+ box-sizing: border-box;
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(0, 0, 0, 0.7);
+ border-radius: 2px;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 4;
+}
+.city-progress .fill { height: 100%; width: 0; background: #7fc4ff; }
+.city-progress.building .fill { background: #ffd54a; }
+/* One small square per building level, scattered around the city icon. */
+.city-buildings {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+.city-building-square {
+ position: absolute;
+ width: 10%;
+ height: 10%;
+ transform: translate(-50%, -50%);
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid rgba(0, 0, 0, 0.6);
+ border-radius: 1px;
+}
.path-seg {
position: absolute;
@@ -579,6 +664,12 @@ input:focus, select:focus { border-color: var(--accent); }
.stat { display: flex; flex-direction: column; align-items: flex-end; }
.stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.stat-value { font-size: 15px; font-variant-numeric: tabular-nums; }
+/* Month-on-month change marker inside a stat: green up, red down, neutral for
+ unchanged, doubled for a move past the indicator's threshold. */
+.stat-arrow { margin-left: 4px; font-size: 11px; font-weight: 700; letter-spacing: -1px; }
+.stat-arrow.up { color: #6ee787; }
+.stat-arrow.down { color: #f27272; }
+.stat-arrow.equal { color: var(--muted); }
/* Approval reads white in the neutral 40–60% band, red when the people
disapprove and green when they approve. */
.stat-value.approval-neutral { color: var(--text); }
@@ -639,39 +730,33 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 12px 18px;
}
-/* The improvement picker opens directly above the build button. */
-.build-anchor { position: relative; display: inline-block; }
-.build-popover {
- position: absolute;
- left: 0;
- bottom: calc(100% + 8px);
- min-width: 300px;
- background: var(--panel-bg, rgba(18, 24, 34, 0.96));
- border: 1px solid rgba(255, 255, 255, 0.14);
- border-radius: 8px;
- padding: 8px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
- z-index: 70;
-}
-.build-popover-title {
- font-size: 12px;
- color: var(--muted);
- margin: 2px 4px 8px;
-}
-.build-option {
+/* One button per buildable tile improvement, laid out in a wrapping row. */
+.tile-improvement-actions {
display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- width: 100%;
- margin: 4px 0;
- text-align: left;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
}
-.build-option .build-name { font-weight: 600; }
-.build-option .build-meta { font-size: 11px; color: var(--muted); }
-.build-option.current { outline: 1px solid rgba(255, 255, 255, 0.4); }
+.improvement-btn { font-size: 12px; }
+.improvement-btn.current { opacity: 0.6; }
+.improvement-btn.remove { border-color: rgba(242, 114, 114, 0.6); }
+/* Buildable improvements are square icons with the cost written beneath. */
+.improvement-icon-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ width: 64px;
+ padding: 5px 4px;
+}
+.improvement-icon { width: 40px; height: 40px; display: block; }
+.improvement-cost { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
+
.info-section.hidden { display: none !important; }
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
+.city-production-label { font-size: 12px; color: var(--muted); margin-top: 4px; }
+.city-production-bar { height: 8px; margin-top: 4px; }
+.city-production-bar > div { height: 100%; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
.tile-coords { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
.tile-gdp-head { font-size: 13px; font-variant-numeric: tabular-nums; }
.tile-gdp-toggle { cursor: pointer; }
@@ -685,6 +770,12 @@ input:focus, select:focus { border-color: var(--accent); }
#unit-health-fill { height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
#unit-health-label { margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
+.unit-cargo { margin-top: 6px; font-size: 12px; color: var(--muted); }
+.unit-stack-list { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; max-height: 140px; overflow-y: auto; }
+.stack-unit { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
+.stack-unit-name { flex: 1; }
+.stack-unit-hp { color: var(--muted); font-variant-numeric: tabular-nums; }
+
.unit-stats { display: flex; gap: 16px; margin-top: 6px; }
.unit-stat {
font-size: 13px;
@@ -960,6 +1051,19 @@ input:focus, select:focus { border-color: var(--accent); }
color: var(--muted);
}
.tab.active { background: rgba(110, 175, 255, 0.22); color: var(--text); }
+/* Secondary tabs inside a page, e.g. the technology themes. */
+.subtabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 18px 8px; }
+.subtab {
+ cursor: pointer;
+ white-space: nowrap;
+ padding: 5px 12px;
+ border-radius: 7px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--muted);
+ font-size: 12px;
+}
+.subtab.active { background: rgba(110, 175, 255, 0.22); color: var(--text); }
.tab-page { display: none; }
/* The active tab fills the rest of the card and lets its body scroll: without
min-height: 0 the flex item would grow past the card and be clipped by the
@@ -1009,6 +1113,43 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
+/* Technology cards are wide, short 3:1 tiles in a grid, so the tab shows many
+ more of them at once than a single stacked list would. */
+#technology-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+ gap: 8px;
+ align-content: start;
+}
+#technology-list .row-card {
+ margin-bottom: 0;
+ aspect-ratio: 3 / 1;
+ overflow: hidden;
+ padding: 8px 10px;
+ gap: 10px;
+}
+#technology-list .row-card .desc,
+#technology-list .row-card .effects {
+ margin: 2px 0;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+#technology-list .row-card .actions { justify-content: flex-start; align-items: flex-start; }
+#technology-list .row-card .actions .aero { min-width: 0; padding: 3px 12px; }
+/* A focused technology fills a bar with the points committed beside it. */
+#technology-list .cost { display: flex; align-items: center; gap: 8px; }
+.tech-progress-track {
+ flex: 1 1 auto;
+ height: 8px;
+ background: rgba(255, 255, 255, 0.14);
+ border-radius: 4px;
+ overflow: hidden;
+}
+.tech-progress-fill { height: 100%; background: linear-gradient(90deg, #4f8cff, #7fd1ff); }
+.tech-progress-text { white-space: nowrap; font-variant-numeric: tabular-nums; }
+
.row-card.conflict-card { display: block; cursor: pointer; }
.row-card.conflict-card:hover { background: rgba(255, 255, 255, 0.09); }
.conflict-detail {
diff --git a/client/index.html b/client/index.html
index 252cc2c..97170cc 100644
--- a/client/index.html
+++ b/client/index.html
@@ -131,7 +131,7 @@
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 89cc068..5ed5f88 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -28,8 +28,6 @@ export class GameScreen {
this.selectedCityId = 0;
// The tile whose info panel is open, if any (right-click inspection).
this._inspectedTile = null;
- // The tile the improvement picker currently targets.
- this._buildTarget = null;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
@@ -84,7 +82,6 @@ export class GameScreen {
this.capabilityModal = new CapabilityModal();
this.feed = new GameFeed($("#game-feed"));
this.$stackMenu = $("#stack-menu");
- this.$buildPopover = $("#tile-build-popover");
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -201,6 +198,8 @@ export class GameScreen {
this._updateFeed(this._news);
this.map.setCatalogue(this.civilisations, this.protoUnits);
+ // Mirror the viewer's repeatable-research bonuses used by prediction.
+ this.map.railSpeed = (state.viewerStats || {}).railSpeed || 0;
this.map.applySnapshot(state);
// Refresh an active economic map mode against the new figures.
this._refreshEconomicMode();
@@ -280,21 +279,22 @@ export class GameScreen {
return data && data.civ === this.localCiv;
});
this._selectUnits(stack.length > 0 ? stack : [unit.id]);
+ // Clicking a unit also reveals the tile (or city) it stands on.
+ this._inspectUnitTile(unit);
};
- this.map.onCitySelected = (id) => {
- const city = (this.snapshot.cities || []).find((c) => c.id === id);
- if (!city || city.civ !== this.localCiv) return;
- this.selectedCityId = id;
- this.selectedUnitIds = [];
- this.map.setSelectedUnits([]);
- this._hideStackMenu();
- this._showCity(city);
+ this.map.onCitySelected = (id) => this._selectCity(id);
+ // A double click goes one step further and opens the full city panel.
+ this.map.onCityOpened = (id) => {
+ if (this._selectCity(id)) this._openCityModal();
};
this.map.onTileRequested = (coords) => this._showTile(coords);
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
this.map.onMoveOrdered = (unitIds, coords) => {
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
if (units.length === 0) return;
+ // Right-clicking is also how troops board a launch and how a loaded launch
+ // puts its passengers ashore.
+ if (this._tryTransportOrder(units, coords)) return null;
return this._orderMove(units, coords);
};
this.map.onAttackOrdered = (unitIds, coords) => {
@@ -309,6 +309,50 @@ export class GameScreen {
};
}
+ // A selected unit is a transport (launch) when its type carries capacity.
+ _isTransport(unit) {
+ const proto = this.protoUnits[unit.proto];
+ return !!(proto && proto.transportCapacity > 0);
+ }
+
+ // Right-click transport orders: infantry board a friendly launch they stand
+ // beside, and a loaded launch puts its passengers ashore on an adjacent land
+ // tile. Returns true when the click was consumed.
+ _tryTransportOrder(units, coords) {
+ const infantry = units.every((unit) => {
+ const proto = this.protoUnits[unit.proto];
+ return proto && proto.infantry;
+ });
+ if (infantry) {
+ const launch = (this.snapshot.units || []).find(
+ (unit) =>
+ unit.civ === this.localCiv &&
+ unit.coords[0] === coords.x &&
+ unit.coords[1] === coords.y &&
+ this._isTransport(unit)
+ );
+ if (launch) {
+ this.sendOrder({ type: "embark", units: units.map((unit) => unit.id), launch: launch.id });
+ return true;
+ }
+ }
+ const transports = units.every(
+ (unit) => this._isTransport(unit) && (unit.cargo || []).length > 0
+ );
+ if (transports) {
+ const tile = this.map.getTile(coords);
+ if (tile && tile.terrainClass === "Land") {
+ this.sendOrder({
+ type: "disembark",
+ units: units.map((unit) => unit.id),
+ coords: [coords.x, coords.y],
+ });
+ return true;
+ }
+ }
+ return false;
+ }
+
// Issues a move for the group, prompting first when the route would enter
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index bd9b514..79824f4 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -4,7 +4,7 @@
// snapshot and forward orders.
import { describeNews } from "../modals.js";
-import { compact, groupDigits, improvements as fmtImprovements } from "../../../shared/text_format.js";
+import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
@@ -19,9 +19,11 @@ import {
pillageSteps,
ROADS,
TRANSPORT_IMPROVEMENTS,
+ transportImprovement,
+ TILE_IMPROVEMENTS,
AIR_STRIKE,
} from "../../../shared/data.js";
-import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors } from "../../../shared/rules.js";
+import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
import { parseKey } from "../../../shared/hex.js";
@@ -77,8 +79,8 @@ export const panelMethods = {
this.sendOrder({ type: "build", city: cityId, building: index, free: true });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
- this.nationModal.onTechnology = (index) =>
- this.sendOrder({ type: "technology", technology: index });
+ this.nationModal.onFocusTechnology = (index) =>
+ this.sendOrder({ type: "focus_technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
this.nationModal.onCampaign = (observer, targetKind, target, direction) =>
@@ -104,33 +106,7 @@ export const panelMethods = {
.on("click", () => this._pillageSelected())
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
-
- // The improvement picker opens above the build button; the choices are
- // delegated, so rebuilding the list never loses the handler.
- $("#tile-build").on("click", (event) => {
- event.stopPropagation();
- this._toggleBuildPopover();
- });
- $("#tile-build-popover").on("click", ".build-choice", (event) => {
- const $choice = $(event.currentTarget);
- if ($choice.prop("disabled")) return;
- // Recompute the target as a fallback, so a re-rendered popover can never
- // lose the tile the choice applies to.
- const target = this._buildTarget || this._buildableTile();
- if (!target) return;
- this._buildImprovement(
- target.coords,
- $choice.attr("data-improvement"),
- $choice.attr("data-free") === "1"
- );
- this._hideBuildPopover();
- });
- $(document).on("mousedown.buildpopover", (event) => {
- if (!$(event.target).closest("#tile-build-popover").length &&
- !$(event.target).closest("#tile-build").length) {
- this._hideBuildPopover();
- }
- });
+ $("#unit-trench").on("click", () => this._digTrenchSelected());
// A click anywhere outside the stack menu dismisses it.
$(document).on("mousedown.stackmenu", (event) => {
@@ -329,6 +305,36 @@ export const panelMethods = {
);
},
+ // Whether the selected units may dig in where they stand: every one is
+ // infantry, sharing one controlled land tile with no trench yet.
+ _canDigTrench(units) {
+ if (units.length === 0) return false;
+ const coords = units[0].coords;
+ if (!units.every((u) => u.coords[0] === coords[0] && u.coords[1] === coords[1])) return false;
+ if (!units.every((u) => {
+ const proto = this.protoUnits[u.proto];
+ return proto && proto.infantry;
+ })) return false;
+ const k = `${coords[0]},${coords[1]}`;
+ const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
+ if (owner !== this.localCiv) return false;
+ const tile = this.map.getTile({ x: coords[0], y: coords[1] });
+ if (!tile || tile.terrainClass !== "Land") return false;
+ return !this.map.trenches.has(k);
+ },
+
+ _updateTrenchButton(units) {
+ const $button = $("#unit-trench");
+ if (!$button.length) return;
+ $button.toggleClass("hidden", !this._canDigTrench(units));
+ },
+
+ _digTrenchSelected() {
+ const units = this._selectedUnits();
+ if (!this._canDigTrench(units)) return;
+ this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
+ },
+
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
@@ -501,15 +507,68 @@ export const panelMethods = {
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
+ // The nation's figures one calendar month ago, when there is one, so each
+ // stat can show whether it rose or fell since.
+ const month = ((state.monthly || [])[this.localCiv]) || null;
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
- const approval = stats.approval === undefined || stats.approval === null ? 0.5 : stats.approval;
- this._updateApprovalStat(approval);
+ // GDP, population and GDP/capita double the arrow past a 10% month-on-month
+ // move; the others only ever show one.
+ this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
+ this._updateStatArrow("stat-gdp", stats.gdp, month && month.gdp, 0.1);
+ this._updateStatArrow("stat-gdppc", stats.gdpPerCapita, month && month.gdpPerCapita, 0.1);
+ this._updateStatArrow("stat-budget", stats.budget, month && month.budget, null);
+ const popularityPoints = stats.popularity || 0;
+ const popularity = popularityDisplay(popularityPoints);
+ this._updateApprovalStat(popularity);
+ // The people drift one point an hour back to neutral, so the estimate is
+ // simply how many points away they are; zero is already there.
+ const pointsAway = Math.abs(popularityPoints);
+ $("#stat-approval").attr(
+ "title",
+ pointsAway <= 0
+ ? "Popularity is exactly neutral"
+ : `Neutrality in about ${pointsAway} h (~${Math.ceil(pointsAway / 24)} d) ` +
+ "at the current drift of one point an hour"
+ );
+ // Popularity doubles its arrow past ten percentage points.
+ this._updateStatArrow(
+ "stat-approval",
+ popularity,
+ month ? popularityDisplay(month.popularity || 0) : null,
+ 0.1,
+ 0.005
+ );
},
- // The summary's approval figure: white while it sits in the neutral band
+ // Appends the month-on-month change marker inside a stat's value: a bold
+ // green up arrow, a red down arrow, doubled once a big move occurred, or an
+ // equals sign when nothing changed. No baseline (January 2000) means none.
+ _updateStatArrow(statId, current, previous, doubleAt = null, equalEpsilon = 0.5) {
+ const $value = $(`#${statId}`);
+ if (!$value.length) return;
+ $value.find(".stat-arrow").remove();
+ if (previous === undefined || previous === null || current === undefined || current === null) {
+ return;
+ }
+ const diff = current - previous;
+ const $arrow = $("
");
+ if (Math.abs(diff) <= equalEpsilon) {
+ $arrow.text("=").addClass("equal");
+ } else {
+ const rising = diff > 0;
+ const double = doubleAt !== null && previous !== 0 &&
+ Math.abs(diff) / Math.abs(previous) > doubleAt;
+ $arrow
+ .text(rising ? (double ? "▲▲" : "▲") : (double ? "▼▼" : "▼"))
+ .addClass(rising ? "up" : "down");
+ }
+ $value.append($arrow);
+ },
+
+ // The summary's popularity figure: white while it sits in the neutral band
// (40–60%), red when the people disapprove and green when they approve.
_updateApprovalStat(approval) {
const $approval = $("#stat-approval");
@@ -545,6 +604,8 @@ export const panelMethods = {
_showUnits(units) {
if (units.length === 0) {
$("#unit-info").addClass("hidden");
+ this._clearStackList();
+ this._updateCargo([]);
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
@@ -559,18 +620,71 @@ export const panelMethods = {
$("#unit-attack, #unit-defense").addClass("hidden");
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
+ this._renderStackList(units);
this._updatePillageButton(units);
+ this._updateTrenchButton(units);
+ this._updateCargo(units);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
+ // The stack panel's checklist: every member of the selected stack, ticked,
+ // so the player can narrow the selection to the units they want to order.
+ _renderStackList(units) {
+ const $list = $("#unit-stack-list");
+ if (!$list.length) return;
+ $list.removeClass("hidden");
+ const signature = units.map((unit) => unit.id).join(",");
+ if (signature !== this._stackListSignature) {
+ this._stackListSignature = signature;
+ $list.empty();
+ for (const unit of units) {
+ const proto = this.protoUnits[unit.proto];
+ const $row = $("
");
+ const $check = $("
")
+ .attr("data-id", unit.id)
+ .prop("checked", true);
+ $check.on("change", () => {
+ const ids = [];
+ $list.find("input:checked").each((_index, element) => {
+ ids.push(Number($(element).attr("data-id")));
+ });
+ this._selectUnits(ids);
+ });
+ $row.append(
+ $check,
+ $("
").text(proto ? proto.name : "Unit"),
+ $("
").text(
+ `${Math.round(unit.hp)}/${unit.maxHp}`
+ )
+ );
+ $list.append($row);
+ }
+ }
+ // Keep the hit-point readouts current even when the set did not change.
+ units.forEach((unit, index) => {
+ $list
+ .find(".stack-unit-hp")
+ .eq(index)
+ .text(`${Math.round(unit.hp)}/${unit.maxHp}`);
+ });
+ },
+
+ _clearStackList() {
+ const $list = $("#unit-stack-list");
+ if ($list.length) $list.addClass("hidden").empty();
+ this._stackListSignature = null;
+ },
+
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
+ this._clearStackList();
this._hideStatTooltip();
this._refreshPanelVisibility();
return;
}
+ this._clearStackList();
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(this._unitLabel(unit, proto));
$("#unit-attack, #unit-defense").removeClass("hidden");
@@ -579,6 +693,8 @@ export const panelMethods = {
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
+ this._updateTrenchButton([unit]);
+ this._updateCargo([unit]);
$("#unit-info").removeClass("hidden");
this._refreshPanelVisibility();
},
@@ -630,11 +746,43 @@ export const panelMethods = {
}
},
+ // A transport shows its berths and who is aboard, and any boarding still
+ // under way; non-transports show nothing.
+ _updateCargo(units) {
+ const $cargo = $("#unit-cargo");
+ if (!$cargo.length) return;
+ const lines = [];
+ for (const unit of units) {
+ const proto = this.protoUnits[unit.proto];
+ if (!proto || !proto.transportCapacity) continue;
+ const cargo = unit.cargo || [];
+ const names = cargo
+ .map((passenger) => {
+ const passengerProto = this.protoUnits[passenger.proto];
+ return passengerProto ? passengerProto.name : "troops";
+ })
+ .join(", ");
+ let text = `Cargo ${cargo.length}/${proto.transportCapacity}`;
+ if (names) text += ` — ${names}`;
+ if (unit.embarking && unit.embarking.count) {
+ text += ` · boarding ${unit.embarking.count} (${Math.ceil(unit.embarking.hoursLeft)} h)`;
+ }
+ lines.push(text);
+ }
+ if (lines.length === 0) {
+ $cargo.addClass("hidden").text("");
+ return;
+ }
+ $cargo.removeClass("hidden").text(lines.join(" · "));
+ },
+
// A city panel shows the tile it stands on (owner, terrain, population, GDP
// per capita, improvements) plus the city's name and its actions; the tile
// fields are the same ones the plain tile panel renders, so nothing is
// duplicated between the two.
- _showCity(city) {
+ // `keepUnits` leaves the unit panel open, so clicking a unit that stands on a
+ // city shows the city's tile detail without dropping the unit selection.
+ _showCity(city, { keepUnits = false } = {}) {
if (!city) {
$("#city-info").addClass("hidden");
$("#tile-info").addClass("hidden");
@@ -644,12 +792,46 @@ export const panelMethods = {
this._inspectedTile = null;
this._renderTileInfo({ x: city.coords[0], y: city.coords[1] });
$("#city-name").text(city.name);
- $("#unit-info").addClass("hidden");
+ $("#city-manage").toggleClass("hidden", city.civ !== this.localCiv);
+ if (!keepUnits) $("#unit-info").addClass("hidden");
$("#tile-info, #city-info").removeClass("hidden");
this._refreshPanelVisibility();
this._updateTileHighlight();
},
+ // Selects one of the player's cities and shows its quick panel. Returns false
+ // when the city is unknown or someone else's. Double-clicking the city icon
+ // uses the same selection before opening the full city screen.
+ _selectCity(id) {
+ const city = (this.snapshot.cities || []).find((c) => c.id === id);
+ if (!city || city.civ !== this.localCiv) return false;
+ this.selectedCityId = id;
+ this.selectedUnitIds = [];
+ this.map.setSelectedUnits([]);
+ this._hideStackMenu();
+ this._showCity(city);
+ return true;
+ },
+
+ // Clicking a unit opens the panel for the tile it stands on (or the city it
+ // occupies), alongside the unit panel rather than replacing it.
+ _inspectUnitTile(unit) {
+ const coords = { x: unit.coords[0], y: unit.coords[1] };
+ const city = (this.snapshot.cities || []).find(
+ (c) => c.coords[0] === coords.x && c.coords[1] === coords.y
+ );
+ if (city) {
+ this._showCity(city, { keepUnits: true });
+ return;
+ }
+ this._inspectedTile = coords;
+ this._renderTileInfo(coords);
+ $("#city-info").addClass("hidden");
+ $("#tile-info").removeClass("hidden");
+ this._refreshPanelVisibility();
+ this._updateTileHighlight();
+ },
+
_showTile(coords) {
// Inspecting a tile drops any city or unit selection, otherwise the next
// snapshot would immediately switch the panel back to the old selection.
@@ -688,98 +870,206 @@ export const panelMethods = {
const cityImprovements = (city ? city.improvements || [] : []).filter(
(name) => name !== ROADS.improvementName && name !== "Railway"
);
+ // Military works and trenches are shown alongside the transport network.
+ const works = [];
+ if (this.map.trenches.has(k)) works.push("Trench");
+ const militaryWorks = this.map.tileImprovements.get(k);
+ if (militaryWorks) {
+ const proto = TILE_IMPROVEMENTS.find((entry) => entry.id === militaryWorks.id);
+ works.push(proto ? proto.name : militaryWorks.id);
+ }
$("#tile-improvements").text(
- `Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport])))}`
+ `Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}`
);
- // Only a land tile the player controls can carry a new improvement.
- const buildable = owner === this.localCiv &&
+ this._renderTileWorks(coords);
+ this._renderCityProduction(city);
+ // Only a land tile the player controls can carry a new improvement. A city
+ // improves itself, so it never offers the build buttons.
+ const buildable = !city &&
+ owner === this.localCiv &&
owner >= 0 &&
!!tile &&
tile.terrainClass === "Land";
- $("#tile-build").toggleClass("hidden", !buildable);
- if (!buildable) this._hideBuildPopover();
- },
-
- // The tile the improvement picker targets, or null when it cannot be built on.
- _buildableTile() {
- let coords = this._inspectedTile;
- // A selected city still stands on an improvable tile.
- if (!coords && this.selectedCityId) {
- const city = this._city(this.selectedCityId);
- if (city) coords = { x: city.coords[0], y: city.coords[1] };
- }
- if (!coords) return null;
- const k = `${coords.x},${coords.y}`;
- if ((this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1) !== this.localCiv) {
- return null;
- }
- const tile = this.map.getTile(coords);
- if (!tile || tile.terrainClass !== "Land" || this.localCiv < 0) return null;
- return { coords, k, tile };
- },
-
- _toggleBuildPopover() {
- if (!this.$buildPopover || !this.$buildPopover.length) return;
- if (!this.$buildPopover.hasClass("hidden")) {
- this._hideBuildPopover();
- return;
- }
- this._renderBuildPopover();
- },
-
- // Lists the transport improvements with their price and hourly upkeep, greying
- // out the one already on the tile and anything the budget cannot afford.
- _renderBuildPopover() {
- const target = this._buildableTile();
- const $pop = this.$buildPopover;
- if (!target || !$pop || !$pop.length) return;
- this._buildTarget = target;
- $pop.empty();
- $pop.append($("
").text("Build transport"));
- const current = this.map.railways.has(target.k)
+ const current = this.map.railways.has(k)
? "railway"
- : this.map.roads.has(target.k)
+ : this.map.roads.has(k)
? "road"
: null;
+ this._renderImprovementActions(coords, buildable, current);
+ },
+
+ // The military improvement under construction on this tile, drawn with the
+ // same progress bar as a city's production.
+ _renderTileWorks(coords) {
+ const $el = $("#tile-works");
+ if (!$el.length) return;
+ $el.empty();
+ const queue = (this.snapshot.tileWorks || [])[this.localCiv] || [];
+ const work = queue.find(
+ (entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
+ );
+ if (!work) return;
+ const proto = TILE_IMPROVEMENTS.find((improvement) => improvement.id === work.id);
+ const name = proto ? proto.name : work.id;
+ const total = Math.max(work.totalHours || 0, 0.0001);
+ const elapsed = work.elapsedHours || 0;
+ const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
+ const remaining = Math.max(0, Math.ceil(total - elapsed));
+ const $bar = $("
");
+ $bar.append($("
").css("width", `${pct}%`));
+ $el.append(
+ $("
").text(`Building ${name} — ${remaining} h left`),
+ $bar
+ );
+ },
+
+ // The city panel's quick view of what is currently being produced there, so
+ // the player can read progress without opening the city modal.
+ _renderCityProduction(city) {
+ const $el = $("#city-production");
+ if (!$el.length) return;
+ $el.empty();
+ const queue = city ? this._trainingQueue(city.id) : null;
+ const entry = queue && queue[0];
+ if (!entry) return;
+ const building = entry.kind === "building";
+ const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
+ const name = proto ? proto.name : (building ? "Building" : "Unit");
+ const verb = building ? "Building" : "Training";
+ const total = Math.max(entry.totalHours || 0, 0.0001);
+ const elapsed = entry.elapsedHours || 0;
+ const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
+ const remaining = Math.max(0, Math.ceil(total - elapsed));
+ const $bar = $("
");
+ $bar.append($("
").css("width", `${pct}%`));
+ $el.append(
+ $("
").text(`${verb} ${name} — ${remaining} h left`),
+ $bar
+ );
+ },
+
+ // A square icon button for one buildable improvement: the type's icon with
+ // its cost beneath, and the name (plus upkeep and build time) in the tooltip.
+ _improvementButton(improvement, { className, costLabel, title, disabled, current }) {
+ const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
+ const $button = $(`
`)
+ .attr("title", title)
+ .prop("disabled", disabled)
+ .toggleClass("current", current);
+ $button.append(
+ $("
![]()
")
+ .attr("src", `assets/${improvement.icon}`)
+ .attr("alt", improvement.name),
+ $("
").text(costLabel)
+ );
+ return $button;
+ },
+
+ // One square icon button per transport and military improvement, plus a
+ // control to remove the one already standing on the tile. Shown only on a
+ // controlled land tile; an unaffordable improvement is greyed out.
+ _renderImprovementActions(coords, buildable, current) {
+ const $row = $("#tile-improvement-actions");
+ if (!$row.length) return;
+ $row.empty();
+ if (!buildable) return;
const budget = (this.snapshot.viewerStats || {}).budget || 0;
const testing = !!(this.snapshot && this.snapshot.testing);
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const affordable = budget >= improvement.buildCost;
- const $button = $("
");
- $button.attr("data-improvement", improvement.id);
- $button.append(
- $("
").append(
- $("
").text(improvement.name),
- $("
").text(
- `€${groupDigits(improvement.buildCost)} · €${groupDigits(improvement.maintenance)}/tile·h`
- )
- ),
- $("
").text(
- isCurrent ? "Built" : `${Math.round(1 / improvement.movementCostMultiplier)}× grass`
- )
- );
- $button.prop("disabled", isCurrent || !affordable);
- $button.attr(
- "title",
- isCurrent
- ? "Already built on this tile"
- : affordable
- ? `Build ${improvement.name} here`
- : `Not enough budget (€${groupDigits(improvement.buildCost)} needed)`
- );
- if (isCurrent) $button.addClass("current");
- $pop.append($button);
+ const title = isCurrent
+ ? `${improvement.name} — already built on this tile`
+ : affordable
+ ? `${improvement.name} — €${groupDigits(improvement.maintenance)}/tile·h upkeep`
+ : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
+ const $button = this._improvementButton(improvement, {
+ className: "transport-btn",
+ costLabel: `€${compact(improvement.buildCost)}`,
+ title,
+ disabled: isCurrent || !affordable,
+ current: isCurrent,
+ });
+ $button.on("click", () => this._buildImprovement(coords, improvement.id, false));
+ $row.append($button);
// A local test game may raise it for free and at once.
if (testing && !isCurrent) {
- const $free = $("
");
- $free.attr("data-improvement", improvement.id);
- $free.attr("data-free", "1");
- $free.text(`${improvement.name}: free & instant`);
- $pop.append($free);
+ const $free = this._improvementButton(improvement, {
+ className: "free-button",
+ costLabel: "free",
+ title: `${improvement.name} — build free and instantly (testing)`,
+ disabled: false,
+ current: false,
+ });
+ $free.on("click", () => this._buildImprovement(coords, improvement.id, true));
+ $row.append($free);
}
}
- $pop.removeClass("hidden");
+ if (current) {
+ const name = transportImprovement(current) ? transportImprovement(current).name : current;
+ const $remove = $("
")
+ .text(`Remove ${name}`)
+ .on("click", () => this._removeImprovement(coords));
+ $row.append($remove);
+ }
+
+ // Military tile improvements. They cost money and take production time, so
+ // the tooltip names the expected hours alongside the upkeep.
+ const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
+ const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
+ const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
+ for (const improvement of TILE_IMPROVEMENTS) {
+ if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
+ const built = !!(military && military.id === improvement.id);
+ const affordable = budget >= improvement.buildCost;
+ const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
+ const title = military && !built
+ ? `${improvement.name} — another improvement already stands here`
+ : affordable
+ ? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build`
+ : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
+ const $button = this._improvementButton(improvement, {
+ className: "tile-btn",
+ costLabel: `€${compact(improvement.buildCost)}`,
+ title,
+ disabled: !!military || !affordable,
+ current: built,
+ });
+ $button.on("click", () => this._buildTileImprovement(coords, improvement.id));
+ $row.append($button);
+ }
+ if (military) {
+ const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id;
+ const $remove = $("
")
+ .text(`Remove ${name}`)
+ .on("click", () => this._removeTileImprovement(coords));
+ $row.append($remove);
+ }
+ },
+
+ // Whether a land tile touches the sea, so a coastal cannon may be raised.
+ _tileIsCoastal(coords) {
+ if (!this.map.topology) return false;
+ for (const neighbour of this.map.topology.neighbours(coords.x, coords.y)) {
+ const tile = this.map.getTile(neighbour);
+ if (tile && tile.terrainClass === "Sea") return true;
+ }
+ return false;
+ },
+
+ _buildTileImprovement(coords, id) {
+ this.sendOrder({
+ type: "build_tile_improvement",
+ coords: [coords.x, coords.y],
+ improvement: id,
+ });
+ },
+
+ _removeTileImprovement(coords) {
+ this.sendOrder({
+ type: "remove_tile_improvement",
+ coords: [coords.x, coords.y],
+ });
},
_buildImprovement(coords, id, free = false) {
@@ -792,10 +1082,11 @@ export const panelMethods = {
this.sendOrder(order);
},
- _hideBuildPopover() {
- if (this.$buildPopover && this.$buildPopover.length) {
- this.$buildPopover.addClass("hidden");
- }
+ _removeImprovement(coords) {
+ this.sendOrder({
+ type: "remove_improvement",
+ coords: [coords.x, coords.y],
+ });
},
// A short, readable label for the tile: its owner's demonym, the terrain and
@@ -1099,6 +1390,10 @@ export const panelMethods = {
civs: this.civilisations,
ethnicMakeup: cityStats.ethnicMakeup || [],
approval: cityStats.approval,
+ // The region's tax base and approval price an upgrade's expected income.
+ base: cityStats.base || 0,
+ // Construction technology shortens the displayed build times.
+ constructionSpeed: stats.constructionSpeed || 0,
taxMultiplier: this._taxMultiplier(cityStats.approval),
};
},
@@ -1117,10 +1412,11 @@ export const panelMethods = {
const queue = this._trainingQueue(this.selectedCityId);
this.cityModal.renderQueue(queue);
this._updateCityProgress(queue);
+ const cityStats = this._cityStats(this.selectedCityId);
+ this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
const city = this._city(this.selectedCityId);
if (city) {
this.cityModal.refreshTrainList(this._trainableUnits(city));
- const cityStats = this._cityStats(this.selectedCityId);
this.cityModal.renderPeople(
cityStats.ethnicMakeup || [],
cityStats.approval,
@@ -1132,7 +1428,8 @@ export const panelMethods = {
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {},
stats.gdp || 0,
- this._queuedBuildingLevels(this.selectedCityId)
+ this._queuedBuildingLevels(this.selectedCityId),
+ { base: cityStats.base || 0, approval: cityStats.approval }
);
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
},
@@ -1198,14 +1495,31 @@ export const panelMethods = {
this._researched,
stats.gdp || 0,
stats.budget || 0,
- stats.research || 0,
+ stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
- this._politicsInfo()
+ this._politicsInfo(),
+ this._techInfo()
);
},
+ // The focused-research figures the technology tab reads from the snapshot.
+ _techInfo() {
+ const snapshot = this.snapshot || {};
+ const focus = (snapshot.focusTechnology || [])[this.localCiv];
+ const focusIndex = focus === undefined || focus === null ? -1 : focus;
+ // Progress is kept per technology, so a focus that was switched away from
+ // still shows what it had committed when it is selected again.
+ const byTech = (snapshot.techProgress || [])[this.localCiv] || {};
+ return {
+ focus: focusIndex,
+ progress: focusIndex >= 0 ? byTech[focusIndex] || 0 : 0,
+ progressByTech: byTech,
+ repeatCounts: (snapshot.repeatCounts || [])[this.localCiv] || {},
+ };
+ },
+
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
@@ -1214,11 +1528,12 @@ export const panelMethods = {
this._researched,
stats.gdp || 0,
stats.budget || 0,
- stats.research || 0,
+ stats.researchRate || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts,
- this._politicsInfo()
+ this._politicsInfo(),
+ this._techInfo()
);
},
@@ -1249,12 +1564,18 @@ export const panelMethods = {
},
_openNewsModal() {
- this.newsModal.show(this._news, this.civilisations);
+ this.newsModal.show(this._newsForModal(), this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
- this.newsModal.refresh(this._news, this.civilisations);
+ this.newsModal.refresh(this._newsForModal(), this.civilisations);
+ },
+
+ // The news list, without the repeatable-research notices: those still reach
+ // the player through the corner feed, but are not news events.
+ _newsForModal() {
+ return (this._news || []).filter((entry) => !entry.repeatable);
},
// Pops newly arrived news into the corner feed. The first snapshot only
diff --git a/client/js/map_view.js b/client/js/map_view.js
index e66c238..9a93831 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -100,6 +100,11 @@ export class MapView {
this.roads = new Set();
// Player-built railway tiles, keyed "x,y". A tile is a road or a railway.
this.railways = new Set();
+ // Trenches, and military improvements keyed "x,y" -> { id, owner, hp }.
+ this.trenches = new Set();
+ this.tileImprovements = new Map();
+ this._warfareSignature = null;
+ this._warfareViews = new Map();
this.civilisations = [];
this.localCiv = -1;
this.protoUnits = [];
@@ -114,6 +119,11 @@ export class MapView {
this.economicKind = null;
this.economicValues = new Map();
this.economicRange = { min: 0, max: 0 };
+ // Value transform for the active economic mode (population reads logs).
+ this.economicScale = null;
+ // The viewer's trains-technology rail bonus, mirrored so predicted movement
+ // along railways matches the server.
+ this.railSpeed = 0;
this._economicSignatureDone = null;
// WebGL fog is rebuilt from the frame loop, coalesced (see `_flushFog`).
this._fogDirty = false;
@@ -181,6 +191,7 @@ export class MapView {
this.onUnitSelected = () => {};
this.onCitySelected = () => {};
+ this.onCityOpened = () => {};
this.onTileRequested = () => {};
this.onMoveOrdered = () => {};
this.onAttackOrdered = () => {};
diff --git a/client/js/map_view/economic.js b/client/js/map_view/economic.js
index 72061a7..dfd4208 100644
--- a/client/js/map_view/economic.js
+++ b/client/js/map_view/economic.js
@@ -24,6 +24,9 @@ export const ECONOMIC_GRADIENT = [
"#00204d",
];
+// A population map is dominated by a handful of huge cities, which would
+// otherwise crush every smaller settlement to the bottom of the scale, so it is
+// read logarithmically; GDP per capita stays linear.
export const ECONOMIC_MODES = {
gdp: {
id: "gdp",
@@ -34,6 +37,7 @@ export const ECONOMIC_MODES = {
id: "population",
label: "Population",
format: (value) => groupDigits(Math.round(value)),
+ scale: (value) => Math.log1p(Math.max(0, value)),
},
};
@@ -42,8 +46,14 @@ export function isEconomicMode(mode) {
}
// Where a value sits on the scale, clamped to 0..1. A flat range (every tile
-// the same) sits in the middle so the map reads as a single mid colour.
-export function economicFraction(value, min, max) {
+// the same) sits in the middle so the map reads as a single mid colour. An
+// optional `scale` maps the values first (the population mode reads its log).
+export function economicFraction(value, min, max, scale = null) {
+ if (scale) {
+ value = scale(value);
+ min = scale(min);
+ max = scale(max);
+ }
if (!(max > min)) return 0.5;
const t = (value - min) / (max - min);
return Math.max(0, Math.min(1, t));
@@ -51,8 +61,8 @@ export function economicFraction(value, min, max) {
// The colour for a value, as a "#rrggbb" string, interpolated between the two
// gradient stops that bracket it.
-export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT) {
- const t = economicFraction(value, min, max);
+export function economicColour(value, min, max, stops = ECONOMIC_GRADIENT, scale = null) {
+ const t = economicFraction(value, min, max, scale);
const scaled = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(scaled));
const blend = scaled - index;
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index ee701be..86bea84 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -3,6 +3,7 @@
import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
+import { tileImprovementById } from "../../../shared/data/improvements.js";
import { BATTLE_SIDE_OFFSET, CAMERA_TILT } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
import { bootLog } from "../loading.js";
@@ -26,6 +27,7 @@ export const entityMethods = {
bootLog.step("Snapshot received from the server");
this.ensureTerrain(state.seed, state.mapConfig || null);
this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements);
+ this.setWarfare(state.trenches || [], state.tileImprovements || [], state.versions && state.versions.warfare);
bootLog.step("Mirrored the transport network");
this.localCiv = state.viewer;
this._applyTerritory(state.territory, state.versions && state.versions.territory);
@@ -34,7 +36,7 @@ export const entityMethods = {
this._applyKnowledge(state.explored, state.visible);
bootLog.step("Mirrored the fog of war");
this._applyBattles(state.battles);
- this._syncCities(state.cities);
+ this._syncCities(state.cities, state.training);
this._syncUnits(state.units);
bootLog.step("Placed the cities and units");
if (!this._centered) this._centerOnCapital(state.cities);
@@ -78,6 +80,51 @@ export const entityMethods = {
}
},
+ // Mirrors the trenches and military improvements from the snapshot and draws
+ // one marker per tile. A signature keeps the markers from being rebuilt when
+ // nothing moved.
+ setWarfare(trenches, improvements, version = undefined) {
+ const signature = `${version}:${trenches.length}:${improvements.length}`;
+ if (version !== undefined && signature === this._warfareSignature) return;
+ this._warfareSignature = signature;
+ this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
+ this.tileImprovements = new Map();
+ for (const [x, y, id, owner, hp] of improvements) {
+ this.tileImprovements.set(key(x, y), { id, owner, hp });
+ }
+ this._renderWarfare();
+ },
+
+ _renderWarfare() {
+ const container = this.$entities && this.$entities[0];
+ if (!container) return;
+ for (const view of this._warfareViews.values()) view.remove();
+ this._warfareViews.clear();
+ for (const k of this.trenches) this._addWarfareMarker(container, k, "trench", null);
+ for (const [k, info] of this.tileImprovements) {
+ this._addWarfareMarker(container, k, "improvement", info);
+ }
+ },
+
+ _addWarfareMarker(container, k, kind, info) {
+ const coords = parseKey(k);
+ const local = mapToLocal(coords.x, coords.y);
+ const el = document.createElement("div");
+ el.className = kind === "trench" ? "trench-marker" : "tile-improvement-marker";
+ if (kind === "improvement") {
+ const proto = tileImprovementById(info.id);
+ const img = document.createElement("img");
+ img.alt = proto ? proto.name : info.id;
+ img.src = `assets/${proto ? proto.icon : "icon_battle.svg"}`;
+ el.dataset.owner = String(info.owner);
+ el.appendChild(img);
+ }
+ el.style.left = `${local.x + this._wrapShift(local.x)}px`;
+ el.style.top = `${local.y * CAMERA_TILT}px`;
+ container.appendChild(el);
+ this._warfareViews.set(`${kind}:${k}`, el);
+ },
+
_applyTerritory(entries, version = undefined) {
if (version !== undefined) {
// A server version is exact and free to compare, unlike hashing the whole
@@ -206,7 +253,7 @@ export const entityMethods = {
this._refreshFog();
},
- _syncCities(cities) {
+ _syncCities(cities, training = null) {
const seen = new Set();
this._cityCiv = new Map();
for (const data of cities) {
@@ -217,6 +264,8 @@ export const entityMethods = {
view = this._createCityView(data);
this._cityViews.set(id, view);
}
+ this._updateCityProgress(view, training && training[id]);
+ this._syncCityBuildings(view, data);
const coords = { x: data.coords[0], y: data.coords[1] };
this._cityCiv.set(key(coords.x, coords.y), data.civ);
const local = mapToLocal(coords.x, coords.y);
@@ -229,6 +278,8 @@ export const entityMethods = {
view.style.left = `${wrappedX}px`;
view.style.top = `${groundY}px`;
const owned = data.civ === this.localCiv;
+ view.dataset.cityId = String(id);
+ view.dataset.civ = String(data.civ);
const label = view._label;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
@@ -263,12 +314,28 @@ export const entityMethods = {
marker.style.backgroundImage = "url(assets/icon_modern_city.svg)";
const statuses = document.createElement("div");
statuses.className = "city-statuses";
+ // One small square per building level, scattered around the icon.
+ const buildings = document.createElement("div");
+ buildings.className = "city-buildings";
+ // A discreet bar under the city name showing the current build or training.
+ const progress = document.createElement("div");
+ progress.className = "city-progress";
+ const fill = document.createElement("div");
+ fill.className = "fill";
+ progress.appendChild(fill);
+ progress.style.display = "none";
view.appendChild(marker);
+ view.appendChild(buildings);
+ view.appendChild(progress);
view.appendChild(statuses);
marker.addEventListener("click", (event) => {
event.stopPropagation();
if (view.dataset.owned === "1") this.onCitySelected(data.id);
});
+ marker.addEventListener("dblclick", (event) => {
+ event.stopPropagation();
+ if (view.dataset.owned === "1") this.onCityOpened(data.id);
+ });
this.$entities[0].appendChild(view);
const label = document.createElement("div");
label.className = "city-label";
@@ -277,6 +344,57 @@ export const entityMethods = {
return view;
},
+ // The little progress bar under a city's name: the first queued unit or
+ // building and how far along it is. Hidden while the city is idle.
+ _updateCityProgress(view, queue) {
+ const bar = view.querySelector(".city-progress");
+ if (!bar) return;
+ const entry = queue && queue[0];
+ if (!entry) {
+ bar.style.display = "none";
+ return;
+ }
+ const total = Math.max(entry.totalHours || 0, 0.0001);
+ const elapsed = entry.elapsedHours || 0;
+ bar.style.display = "block";
+ bar.firstChild.style.width = `${Math.max(0, Math.min(100, (elapsed / total) * 100))}%`;
+ bar.classList.toggle("building", entry.kind === "building");
+ },
+
+ // One small square per building level around the city icon. The scatter is a
+ // hash of the map seed, the city and the square's index, so the same city
+ // always grows the same recognisable pattern.
+ _syncCityBuildings(view, data) {
+ const container = view.querySelector(".city-buildings");
+ if (!container) return;
+ const total = Object.values(data.buildings || {}).reduce(
+ (sum, level) => sum + (level || 0),
+ 0
+ );
+ if (container._count === total) return;
+ container._count = total;
+ container.textContent = "";
+ for (let i = 0; i < total; i++) {
+ const square = document.createElement("div");
+ square.className = "city-building-square";
+ const angle = this._cityHash(data.id, i, 0) * Math.PI * 2;
+ const radius = 0.55 + this._cityHash(data.id, i, 1) * 0.4;
+ square.style.left = `${50 + Math.cos(angle) * radius * 100}%`;
+ square.style.top = `${50 + Math.sin(angle) * radius * 100}%`;
+ container.appendChild(square);
+ }
+ },
+
+ // A stable 0..1 pseudo-random value from the seed, a city id and an index.
+ _cityHash(id, index, salt) {
+ let h = (this.seed | 0) ^ Math.imul(id, 2654435761) ^
+ Math.imul(index + 1, 40503) ^ Math.imul(salt + 1, 2246822519);
+ h = Math.imul(h ^ (h >>> 15), 2246822519);
+ h = Math.imul(h ^ (h >>> 13), 3266489917);
+ h ^= h >>> 16;
+ return (h >>> 0) / 4294967296;
+ },
+
_syncUnits(units) {
const seen = new Set();
// Store every unit's authoritative data first, so motion prediction can
@@ -408,7 +526,10 @@ export const entityMethods = {
_currentTile(motion) {
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
- return motion.path[Math.min(index, motion.path.length - 1)];
+ // A unit that has been ordered off a tile no longer belongs to that
+ // tile's stack: it is grouped with the tile it is heading to, so the
+ // stack splits the moment one of its members marches away.
+ return motion.path[Math.min(index + 1, motion.path.length - 1)];
}
return motion.coords;
},
diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js
index c20c869..a39fef2 100644
--- a/client/js/map_view/modes.js
+++ b/client/js/map_view/modes.js
@@ -30,6 +30,9 @@ export const modeMethods = {
this.economicKind = kind;
this.economicValues = values instanceof Map ? values : new Map(values || []);
this.economicRange = { min: min || 0, max: max || 0 };
+ // The scale a mode reads its figures through (the population mode is
+ // logarithmic). Kept on the view so both renderers agree.
+ this.economicScale = (ECONOMIC_MODES[kind] || {}).scale || null;
const signature = this._economicSignature();
if (signature === this._economicSignatureDone) return;
this._economicSignatureDone = signature;
@@ -131,12 +134,22 @@ export const modeMethods = {
$legend.find("#map-legend-title").text(meta.label);
const { min, max } = this.economicRange;
$legend.find("#map-legend-min").text(meta.format(min));
- $legend.find("#map-legend-mid").text(meta.format((min + max) / 2));
+ $legend.find("#map-legend-mid").text(meta.format(this._economicMidpoint(min, max, meta.scale)));
$legend.find("#map-legend-max").text(meta.format(max));
$legend.find("#map-legend-bar")
.css("background", `linear-gradient(to right, ${ECONOMIC_GRADIENT.join(", ")})`);
},
+ // The value whose scaled position is the midpoint of the ramp, so a
+ // logarithmic legend still labels the middle colour honestly.
+ _economicMidpoint(min, max, scale) {
+ if (scale && max > min) {
+ const mid = (scale(min) + scale(max)) / 2;
+ return Math.expm1(mid);
+ }
+ return (min + max) / 2;
+ },
+
// Paints one DOM terrain tile with its economic colour. Water and tiles with
// no figure stay neutral; the atlas mask still clips the hexagon.
_applyEconomicColour(div, tile, k) {
@@ -145,7 +158,10 @@ export const modeMethods = {
const value = this.economicValues.get(k);
colour = value === undefined
? NEUTRAL_LAND
- : economicColour(value, this.economicRange.min, this.economicRange.max);
+ : economicColour(
+ value, this.economicRange.min, this.economicRange.max,
+ ECONOMIC_GRADIENT, this.economicScale
+ );
}
div.style.backgroundImage = "none";
div.style.backgroundColor = colour;
diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js
index e10f186..2415481 100644
--- a/client/js/map_view/motion.js
+++ b/client/js/map_view/motion.js
@@ -198,26 +198,27 @@ export const motionMethods = {
return mapToLocal(motion.coords.x, motion.coords.y);
},
- // Draws the red dashed line from every ranged attacker with a standing target
- // to the target tile, and a red ring around the target. Both appear only
- // while the attacker (or the unit taking the fire) is selected, and sit on a
- // layer above the terrain but below the unit and city icons.
+ // Draws the red dashed barrage arc from every ranged attacker with a standing
+ // target to the target tile, and a red ring around the target. The shell lobs
+ // in a parabola rather than along the ground, and the dashes march and a
+ // glowing shell travels the curve so the direction is unmistakable. They stay
+ // visible so an ongoing barrage cannot be forgotten, and sit on a layer above
+ // the terrain but below the unit and city icons.
_drawStrikeTargets() {
if (!this.$targets || !this.$targets.length) return;
- // Strike lines only appear for a selected attacker or target.
- if (this._selectedUnitIds.size === 0) {
- if (this.$targets[0].firstChild) this.$targets.empty();
- return;
- }
const fragment = document.createDocumentFragment();
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("class", "target-arcs");
+ const now = Date.now();
+ let arcs = 0;
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const targetIds = this.unitIdsAt(target);
- const highlighted =
- this._selectedUnitIds.has(id) ||
+ // The selected attacker or target stands out from the standing barrages
+ // the player is not looking at.
+ const selected = this._selectedUnitIds.has(id) ||
targetIds.some((targetId) => this._selectedUnitIds.has(targetId));
- if (!highlighted) continue;
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
const targetId = targetIds[0];
@@ -227,21 +228,36 @@ export const motionMethods = {
if (!toPoint) continue;
const a = this._wrappedPosition(fromPoint);
const b = this._wrappedPosition(toPoint);
- const dx = b.x - a.x;
- const dy = b.y - a.y;
- const line = document.createElement("div");
- line.className = "target-line";
- line.style.left = `${a.x}px`;
- line.style.top = `${a.y}px`;
- line.style.width = `${Math.hypot(dx, dy)}px`;
- line.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`;
- fragment.appendChild(line);
+ const distance = Math.hypot(b.x - a.x, b.y - a.y);
+ // A quadratic Bezier lobs the shell: the control point is lifted above the
+ // midpoint, its height compensated for the layer's Y foreshortening so the
+ // arc keeps its shape on screen.
+ const arc = Math.max(18, distance * 0.4) / CAMERA_TILT;
+ const cx = (a.x + b.x) / 2;
+ const cy = (a.y + b.y) / 2 - arc;
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`);
+ path.setAttribute("class", selected ? "target-line target-selected" : "target-line");
+ // Marching dashes flow from the battery towards the target.
+ path.style.strokeDashoffset = `${-((now / 22) % 18)}px`;
+ svg.appendChild(path);
+ // A glowing shell rides the curve to show exactly where it is going.
+ const t = (now / 1300) % 1;
+ const mt = 1 - t;
+ const shell = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ shell.setAttribute("class", "target-shell");
+ shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`);
+ shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`);
+ shell.setAttribute("r", "3.5");
+ svg.appendChild(shell);
+ arcs += 1;
const ring = document.createElement("div");
- ring.className = "target-ring";
+ ring.className = selected ? "target-ring target-selected" : "target-ring";
ring.style.left = `${b.x}px`;
ring.style.top = `${b.y}px`;
fragment.appendChild(ring);
}
+ if (arcs > 0) fragment.insertBefore(svg, fragment.firstChild);
if (!fragment.childNodes.length) {
if (this.$targets[0].firstChild) this.$targets.empty();
return;
@@ -333,6 +349,7 @@ export const motionMethods = {
const civ = unitData.civ;
const targetCiv = this._targetCivFor(unitData, goal);
const occupancy = this._buildOccupancy(unitData.id);
+ const zoc = this._zoneOfControl(civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
@@ -340,20 +357,27 @@ export const motionMethods = {
// Mirror GameState._fastestTileTravelHours: a railway (or a road) may
// undercut the cheapest terrain, and the heuristic must stay admissible if
// A* is to find it.
+ const railCost = unitData.civ === this.localCiv
+ ? TRANSPORT_BY_ID.railway.movementCostMultiplier / (1 + (this.railSpeed || 0))
+ : TRANSPORT_BY_ID.railway.movementCostMultiplier;
const cheapest = Math.min(
this.terrainStats.minMovementCostMultiplier(),
TRANSPORT_BY_ID.road.movementCostMultiplier,
- TRANSPORT_BY_ID.railway.movementCostMultiplier
+ railCost
);
const fastestHours = cheapest / (bestSpeed * CONTROLLED_SPEED_MULTIPLIER);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
(coords) => this.topology.neighbours(coords.x, coords.y),
- (coords, isGoal) => {
- const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
+ (coords, isGoal, from) => {
+ const fromKey = from ? key(from.x, from.y) : "";
+ const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
- enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv));
+ enterCache.set(
+ k,
+ this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv, zoc, from)
+ );
}
return enterCache.get(k);
},
@@ -443,6 +467,23 @@ export const motionMethods = {
return index;
},
+ // Mirrors GameState._zoneOfControl: the tiles hostile ground forces deny to
+ // `civ`, so prediction routes around them the same way the server does.
+ _zoneOfControl(civ) {
+ const denied = new Set();
+ for (const [, data] of this._unitData) {
+ if (data.civ === civ) continue;
+ const proto = this.protoUnits[data.proto];
+ if (!proto || !proto.military || proto.air) continue;
+ const k = key(data.coords[0], data.coords[1]);
+ denied.add(k);
+ for (const neighbour of this.topology.neighbours(data.coords[0], data.coords[1])) {
+ denied.add(key(neighbour.x, neighbour.y));
+ }
+ }
+ return denied;
+ },
+
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
@@ -469,13 +510,19 @@ export const motionMethods = {
// hostile unit or city is only a valid destination (`isGoal`). Foreign
// territory is a wall too, except for the civilisation being targeted, so a
// route never cuts through a third party and provokes a war.
- _canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) {
+ _canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1, zoc = null, from = null) {
const k = key(coords.x, coords.y);
const air = !!(proto && proto.air);
const tile = this.tiles[k];
if (!tile) return false;
if (!air && !this.explored.has(k)) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
+ // Mirrors the server's zone of control: never cross from one denied tile
+ // straight into another, except as a deliberate attack on the goal.
+ if (!air && !isGoal && zoc && from &&
+ zoc.has(key(from.x, from.y)) && zoc.has(key(k))) {
+ return false;
+ }
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
@@ -505,9 +552,15 @@ export const motionMethods = {
let pace = effective;
if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
let multiplier;
- if (this.railways.has(k)) multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
- else if (this.roads.has(k)) multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
- else multiplier = this.terrainStats.movementCostMultiplier(coords);
+ if (this.railways.has(k)) {
+ multiplier = TRANSPORT_BY_ID.railway.movementCostMultiplier;
+ // Only the viewer's own trains technology is known to the browser.
+ if (civ === this.localCiv) multiplier /= 1 + (this.railSpeed || 0);
+ } else if (this.roads.has(k)) {
+ multiplier = TRANSPORT_BY_ID.road.movementCostMultiplier;
+ } else {
+ multiplier = this.terrainStats.movementCostMultiplier(coords);
+ }
return multiplier / pace;
},
};
diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js
index d195c18..e505dc3 100644
--- a/client/js/map_view/webgl.js
+++ b/client/js/map_view/webgl.js
@@ -2041,6 +2041,7 @@ export class GLMapRenderer {
const topo = view.topology;
const period = periodUnits(topo);
const { min, max } = view.economicRange || { min: 0, max: 0 };
+ const scale = view.economicScale || null;
const values = view.economicValues;
const rowStart = [];
for (let y = topo.originY; y < topo.originY + topo.height; y++) {
@@ -2048,7 +2049,7 @@ export class GLMapRenderer {
for (let x = topo.originX; x < topo.originX + topo.width; x++) {
const value = values.get(key(x, y));
if (value === undefined) continue;
- const rgb = parseColour(economicColour(value, min, max));
+ const rgb = parseColour(economicColour(value, min, max, undefined, scale));
const colour = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1];
this._pushTileFill(arr, x, y, colour, seed, period);
}
diff --git a/client/js/modals/city.js b/client/js/modals/city.js
index 95580bc..7187bc5 100644
--- a/client/js/modals/city.js
+++ b/client/js/modals/city.js
@@ -3,7 +3,7 @@
// city's production queue, so the train tab shows both kinds of order.
import { groupDigits, hours } from "../../../shared/text_format.js";
-import { ECONOMY, BUILDINGS } from "../../../shared/data.js";
+import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue } from "../../../shared/data.js";
import {
buildingBuildCost,
buildingRefund,
@@ -37,6 +37,8 @@ export class CityModal {
this.cityId = 0;
this.gdp = 0;
this.coastal = false;
+ // Construction-technology bonus, which shortens every displayed duration.
+ this.constructionSpeed = 0;
// Local test game: each order also offers a free, instant button.
this.testing = false;
this._levels = {};
@@ -71,6 +73,7 @@ export class CityModal {
this.cityId = city.id;
this.gdp = view.gdp || 0;
this.coastal = !!view.coastal;
+ this.constructionSpeed = view.constructionSpeed || 0;
this.testing = !!view.testing;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
@@ -80,7 +83,10 @@ export class CityModal {
this.buildingRows = (view.buildings || []).map((proto, index) =>
this._makeBuildingRow(proto, index)
);
- this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {});
+ this.refreshBuildings(view.budget || 0, view.levels || {}, this.gdp, {}, {
+ base: view.base || 0,
+ approval: view.approval,
+ });
this.$budgetSub.text("Region net +€0/h");
this.renderBudget(null);
this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []);
@@ -113,6 +119,12 @@ export class CityModal {
// -------------------------------------------------------------- training --
+ // The fraction of the base build time a construction technology leaves, so a
+ // 1% level shows a 1% shorter build.
+ _constructionTimeFactor() {
+ return 1 / (1 + (this.constructionSpeed || 0));
+ }
+
_renderTrainList(gdp, trainableUnits) {
this.protoNames = new Map();
this.$trainList.empty();
@@ -126,7 +138,7 @@ export class CityModal {
$("
![]()
").attr({ src: `assets/${proto.icon}`, alt: proto.name })
);
}
- const duration = ECONOMY.trainingHours(gdp, proto.cost);
+ const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
button.append(
$("
").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`)
);
@@ -262,7 +274,32 @@ export class CityModal {
return null;
}
- refreshBuildings(budget, levels, gdp = 0, queued = {}) {
+ // The upgrade button's tooltip: how much the next level is expected to add
+ // to the region's hourly tax, given its GDP base and the people's approval.
+ _setBuildTooltip($button, proto, level, nextLevel, region) {
+ if (!$button || !$button.length) return;
+ const base = region ? region.base || 0 : 0;
+ const approval = region && region.approval !== undefined && region.approval !== null
+ ? region.approval
+ : 1;
+ let delta = 0;
+ for (const effect of proto.effects) {
+ if (effect.stat !== EFFECT_BUDGET_INCOME) continue;
+ delta += effectValue(effect, nextLevel) - effectValue(effect, level);
+ }
+ const amount = base * approval * delta;
+ if (!amount || Math.abs(amount) < 0.5) {
+ $button.removeAttr("title");
+ return;
+ }
+ const sign = amount >= 0 ? "+" : "−";
+ $button.attr(
+ "title",
+ `Expected budget income ${sign}€${groupDigits(Math.round(Math.abs(amount)))}/h`
+ );
+ }
+
+ refreshBuildings(budget, levels, gdp = 0, queued = {}, region = null) {
this._levels = levels || {};
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.buildingRows) {
@@ -277,7 +314,7 @@ export class CityModal {
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
const cost = buildingBuildCost(row.proto, nextLevel);
- const duration = ECONOMY.trainingHours(gdp, cost);
+ const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor();
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
const requirement = this._buildRequirement(row.proto);
if (requirement) parts.push(requirement);
@@ -285,11 +322,13 @@ export class CityModal {
row.$upgrade
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
.prop("disabled", !!requirement || budget < cost);
+ this._setBuildTooltip(row.$upgrade, row.proto, level, nextLevel + 1, region);
if (row.$free) {
// Free does not ignore placement rules (a port still needs the sea).
row.$free
.text(`Free & instant — ${row.proto.name}`)
.prop("disabled", !!requirement);
+ this._setBuildTooltip(row.$free, row.proto, level, nextLevel + 1, region);
}
if (level > 0) {
row.$downgrade
diff --git a/client/js/modals/format.js b/client/js/modals/format.js
index df8a469..7f6ff51 100644
--- a/client/js/modals/format.js
+++ b/client/js/modals/format.js
@@ -14,6 +14,16 @@ export function formatEffect(effect, level) {
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
+ case "infantry_attack":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry attack`;
+ case "infantry_defense":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% infantry defense`;
+ case "artillery_attack":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% artillery attack`;
+ case "construction_speed":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% construction speed`;
+ case "rail_speed":
+ return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% railway speed`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
@@ -55,6 +65,10 @@ export function describeNews(entry, civs) {
: name(entry.ethnicity);
return `${name(entry.civ)} enacted ${policyName} for ${who}`;
}
+ case "technology":
+ return entry.repeatable
+ ? `${name(entry.civ)} improved ${entry.name}`
+ : `${name(entry.civ)} discovered ${entry.name}`;
case "join":
return `${entry.player} took control of ${name(entry.civ)}`;
default:
diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js
index 764b5ea..8e22d89 100644
--- a/client/js/modals/nation.js
+++ b/client/js/modals/nation.js
@@ -5,7 +5,7 @@ import {
governmentChangeCost,
prerequisitesMet,
} from "../../../shared/rules.js";
-import { POLICIES, PROPAGANDA, policyById } from "../../../shared/data.js";
+import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost } from "../../../shared/data.js";
import { renderBudget } from "./budget.js";
import { effectsText, signedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
@@ -34,6 +34,9 @@ export class NationModal {
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
+ // Focused research: which theme tab is open and the current focus figures.
+ this.activeTheme = TECH_THEMES[0].id;
+ this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
this.conflicts = [];
@@ -51,7 +54,7 @@ export class NationModal {
this._politicsState = null;
this._politicsWizardOpen = false;
this.onGovernment = () => {};
- this.onTechnology = () => {};
+ this.onFocusTechnology = () => {};
this.onDeclareWar = () => {};
this.onCampaign = () => {};
this.onCancelCampaign = () => {};
@@ -67,7 +70,7 @@ export class NationModal {
return !this.$modal.hasClass("hidden");
}
- show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = [], politics = null) {
+ show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
@@ -80,10 +83,36 @@ export class NationModal {
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, politics);
+ this._buildTechnologyThemes();
+ this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo);
this.$modal.removeClass("hidden");
}
+ // The technology tab's theme sub-tabs, built once per open. Switching a tab
+ // only shows and hides the rows that are already there.
+ _buildTechnologyThemes() {
+ const $themes = $("#technology-themes");
+ if (!$themes.length) return;
+ $themes.empty();
+ for (const theme of TECH_THEMES) {
+ const $button = $("
").text(theme.name);
+ if (theme.id === this.activeTheme) $button.addClass("active");
+ $button.on("click", () => {
+ this.activeTheme = theme.id;
+ $themes.find(".subtab").removeClass("active");
+ $button.addClass("active");
+ this._applyTechnologyFilter();
+ });
+ $themes.append($button);
+ }
+ }
+
+ _applyTechnologyFilter() {
+ for (const row of this.technologyRows) {
+ if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme);
+ }
+ }
+
// The national budget, drawn by the shared table renderer. Building types
// expand to their per-city cost.
_renderBudget(breakdown) {
@@ -170,16 +199,17 @@ export class NationModal {
const $action = $("
");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
- else this.onTechnology(index);
+ else this.onFocusTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
- return { proto, index, kind, $name, $effects, $cost, $action };
+ return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
- refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null, politics = null) {
+ refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null) {
this.currentGovernment = currentGovernment;
+ if (techInfo) this.techInfo = techInfo;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
if (conflicts !== null) this.conflicts = conflicts || [];
@@ -187,7 +217,9 @@ export class NationModal {
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
- this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
+ // Research has no pool: every point flows into the focused technology, so
+ // the panel shows the rate instead of a stockpile.
+ this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`);
const breakdown = finances || {};
const net = breakdown.net || 0;
@@ -213,11 +245,16 @@ export class NationModal {
}
}
+ const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
- row.$name.text(proto.name);
- row.$effects.text(effectsText(proto.effects, 1));
- if (this.researched.has(row.index)) {
+ const repeat = repeatCounts[proto.id] || 0;
+ const focused = this.techInfo.focus === row.index;
+ row.$name.text(repeat > 0 ? `${proto.name} — Level ${repeat}` : proto.name);
+ // Show the effect of the next level, so a repeatable's diminishing gain
+ // is clear before it is chosen.
+ row.$effects.text(effectsText(proto.effects, repeat > 0 ? repeat : 1));
+ if (this.researched.has(row.index) && !proto.repeatable) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
@@ -229,11 +266,28 @@ export class NationModal {
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
+ } else if (focused) {
+ // A focused technology fills a progress bar, with the committed and
+ // total points to its right.
+ const cost = technologyCost(proto, repeat);
+ const progress = this.techInfo.progress || 0;
+ const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
+ row.$cost.empty().append(
+ $("
").append(
+ $("
").css("width", `${pct}%`)
+ ),
+ $("
").text(
+ `${groupDigits(Math.round(progress))} / ${groupDigits(Math.round(cost))}`
+ )
+ );
+ row.$action.text("Focused").prop("disabled", true);
} else {
- row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`);
- row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
+ const cost = technologyCost(proto, repeat);
+ row.$cost.text(`Cost ${groupDigits(Math.round(cost))} research points`);
+ row.$action.text("Focus").prop("disabled", false);
}
}
+ this._applyTechnologyFilter();
this._renderDiplomacy();
this._renderConflicts();
@@ -624,7 +678,7 @@ export class NationModal {
$("
").text(
"Spend money to warm or cool a population's view of another people or government. " +
"Your cultural impact decides how strongly the message lands, and the last " +
- "few percent are the hardest to win."
+ "few popularity points are the hardest to win."
)
);
$card.append(
diff --git a/server/game_server.js b/server/game_server.js
index f3ec327..e0b6daf 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -172,12 +172,24 @@ export class GameServer extends EventEmitter {
return this._handleBuild(civ, order);
case "build_improvement":
return this._handleBuildImprovement(civ, order);
+ case "remove_improvement":
+ return this._handleRemoveImprovement(civ, order);
+ case "dig_trench":
+ return this._handleDigTrench(civ, order);
+ case "build_tile_improvement":
+ return this._handleBuildTileImprovement(civ, order);
+ case "remove_tile_improvement":
+ return this._handleRemoveTileImprovement(civ, order);
+ case "embark":
+ return this._handleEmbark(civ, order);
+ case "disembark":
+ return this._handleDisembark(civ, order);
case "demolish":
return this._handleDemolish(civ, order);
case "government":
return this._handleGovernment(civ, order);
- case "technology":
- return this._handleTechnology(civ, order);
+ case "focus_technology":
+ return this._handleFocusTechnology(civ, order);
case "declare_war":
return this._handleDeclareWar(civ, order);
case "campaign":
@@ -292,12 +304,47 @@ export class GameServer extends EventEmitter {
);
}
+ // Tears a road or railway off a tile the player controls.
+ _handleRemoveImprovement(civ, order) {
+ return this.state.requestRemoveImprovement(civ, coordsOf(order.coords));
+ }
+
+ _handleDigTrench(civ, order) {
+ const units = this._ownUnitIds(civ, order);
+ if (units.length === 0) return false;
+ return this.state.requestDigTrench(units);
+ }
+
+ _handleBuildTileImprovement(civ, order) {
+ return this.state.requestBuildTileImprovement(
+ civ,
+ coordsOf(order.coords),
+ String(order.improvement || "")
+ );
+ }
+
+ _handleRemoveTileImprovement(civ, order) {
+ return this.state.requestRemoveTileImprovement(civ, coordsOf(order.coords));
+ }
+
+ _handleEmbark(civ, order) {
+ const units = this._ownUnitIds(civ, order);
+ if (units.length === 0) return false;
+ return this.state.requestEmbark(units, Number(order.launch));
+ }
+
+ _handleDisembark(civ, order) {
+ const units = this._ownUnitIds(civ, order);
+ if (units.length === 0) return false;
+ return this.state.requestDisembark(units, coordsOf(order.coords));
+ }
+
_handleGovernment(civ, order) {
return this.state.requestSetGovernment(civ, Number(order.government));
}
- _handleTechnology(civ, order) {
- return this.state.requestResearchTechnology(civ, Number(order.technology));
+ _handleFocusTechnology(civ, order) {
+ return this.state.requestFocusTechnology(civ, Number(order.technology));
}
_handleDeclareWar(civ, order) {
diff --git a/shared/data/buildings.js b/shared/data/buildings.js
index 8857ae7..e719129 100644
--- a/shared/data/buildings.js
+++ b/shared/data/buildings.js
@@ -9,7 +9,7 @@ export const BUILDINGS = [
id: "market",
name: "Market",
description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.",
- baseCost: 5_000_000,
+ baseCost: 500_000,
costAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
@@ -19,7 +19,7 @@ export const BUILDINGS = [
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises the tax collected from the city's region.",
- baseCost: 10_000_000,
+ baseCost: 1_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
diff --git a/shared/data/combat.js b/shared/data/combat.js
index 608111e..219a8b9 100644
--- a/shared/data/combat.js
+++ b/shared/data/combat.js
@@ -14,6 +14,8 @@ export const BATTLE = {
gdpPerCapitaLossPerDay: 0.3,
recoveryDelayDays: 30,
recoveryPerDay: 100,
+ // Structures on a battlefield take this much damage each day of fighting.
+ structureDamagePerDay: 120,
};
// Pillaging: each unit removes this fraction of the tile's remaining
@@ -25,6 +27,15 @@ export const PILLAGE = {
healPerPeople: 1000,
};
+// Trenches: infantry dug into a tile are much harder to dislodge, at the cost
+// of the local economy. The defense figures are multipliers on a unit's base
+// defense; melee attackers get no bonus, artillery is heavily blunted.
+export const TRENCH = {
+ gdpMultiplier: 0.8,
+ meleeDefenseMultiplier: 2.0,
+ artilleryDefenseMultiplier: 3.0,
+};
+
// Sustained bombardment: a ground or naval battery ordered onto a target fires
// one volley every in-game hour. Each volley deals this fraction of the damage
// a single strike would (attack minus the defender's effective defense), so a
@@ -33,6 +44,12 @@ export const BOMBARD = {
hourlyDamageFraction: 0.1,
};
+// Bombarding a city that has no garrison left: each point of damage kills this
+// many people and leaves the usual battle scar on its tile.
+export const CITY_BOMBARD = {
+ populationPerDamage: 10,
+};
+
// Aircraft sorties: a plane flies to its target, bombs once, returns to a
// friendly airport and then cannot strike again for `cooldownHours` (two days).
// Each attacking aircraft costs `cost` from its nation's budget, charged up
diff --git a/shared/data/effects.js b/shared/data/effects.js
index b7020fd..babcafb 100644
--- a/shared/data/effects.js
+++ b/shared/data/effects.js
@@ -6,6 +6,13 @@ export const EFFECT_GDP = "gdp";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
+// Repeatable technology effects. Each is a fraction that stacks with every
+// further level researched.
+export const EFFECT_INFANTRY_ATTACK = "infantry_attack";
+export const EFFECT_INFANTRY_DEFENSE = "infantry_defense";
+export const EFFECT_ARTILLERY_ATTACK = "artillery_attack";
+export const EFFECT_CONSTRUCTION_SPEED = "construction_speed";
+export const EFFECT_RAIL_SPEED = "rail_speed";
export function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
diff --git a/shared/data/improvements.js b/shared/data/improvements.js
index 6488e8a..2dd1478 100644
--- a/shared/data/improvements.js
+++ b/shared/data/improvements.js
@@ -15,6 +15,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.5,
buildCost: 50_000_000,
maintenance: 100_000,
+ icon: "icon_road.svg",
},
{
id: "railway",
@@ -23,6 +24,7 @@ export const TRANSPORT_IMPROVEMENTS = [
movementCostMultiplier: 0.25,
buildCost: 150_000_000,
maintenance: 300_000,
+ icon: "icon_railway.svg",
},
];
@@ -33,3 +35,57 @@ export const TRANSPORT_BY_ID = Object.fromEntries(
export function transportImprovement(id) {
return TRANSPORT_BY_ID[id] || null;
}
+
+// Player-buildable military tile improvements. Unlike transport, these take
+// construction time (the same GDP-scaled formula as buildings and units) and
+// cost an hourly maintenance. A tile may carry one of these plus a road or
+// railway. `defense` is the extra fraction of base defense granted to units
+// stationed there against melee and artillery fire.
+export const TILE_IMPROVEMENTS = [
+ {
+ id: "fortifications",
+ name: "Fortifications",
+ description:
+ "Earthworks and bunkers. Holds a permanent area of control, shelters its garrison and takes damage in combat.",
+ buildCost: 200_000_000,
+ maintenance: 10_000,
+ vision: 2,
+ defense: { melee: 2.0, artillery: 4.0 },
+ permanentControl: true,
+ takesCombatDamage: true,
+ icon: "icon_fortifications.svg",
+ },
+ {
+ id: "radar_tower",
+ name: "Radar tower",
+ description:
+ "Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
+ buildCost: 60_000_000,
+ maintenance: 1000,
+ vision: 5,
+ takesRangedDamage: true,
+ capturable: true,
+ icon: "icon_radar_tower.svg",
+ },
+ {
+ id: "coastal_cannon",
+ name: "Coastal cannon",
+ description:
+ "Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
+ buildCost: 120_000_000,
+ maintenance: 2000,
+ range: 2,
+ automatic: true,
+ takesDamage: true,
+ coastal: true,
+ icon: "icon_coastal_cannon.svg",
+ },
+];
+
+export const TILE_IMPROVEMENT_BY_ID = Object.fromEntries(
+ TILE_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
+);
+
+export function tileImprovementById(id) {
+ return TILE_IMPROVEMENT_BY_ID[id] || null;
+}
diff --git a/shared/data/politics.js b/shared/data/politics.js
index 3217fce..b9ac68a 100644
--- a/shared/data/politics.js
+++ b/shared/data/politics.js
@@ -28,6 +28,34 @@ export const APPROVAL = {
taxFloorFraction: 0.1,
};
+// Popularity is counted in integer points, centred on 0 (neutral). The whole
+// 0..1 opinion scale maps to -100..+100 points, one point being half a percent.
+// What the player sees is a logistic of those points: 0 points reads 50%, +100
+// reads 75% and -100 reads 25%. Opinions drift one point an hour toward
+// neutrality, and war, captures and casualties move them in points.
+export const POPULARITY = {
+ opinionPerPoint: 0.005,
+ // Steepness chosen so display(0) = 0.5 and display(100) = 0.75.
+ logisticScale: Math.log(3) / 100,
+ // Points a population's opinion of its government moves toward neutral each
+ // hour, in either direction.
+ driftPerHour: 1,
+ // Declaring war costs the aggressor this many points with every population.
+ declareWar: 8,
+ // Being declared on grants the victim this many points with its own people,
+ // but only when the aggressor was already loathed there (below threshold).
+ declaredOnBoost: 15,
+ declaredOnThreshold: 0.15,
+ // Points won or lost per military unit destroyed, and per city taken.
+ unitLoss: 1,
+ unitKill: 1,
+ cityLoss: 10,
+ cityCapture: 10,
+ // Pillaging a tile costs the aggressor standing with the whole world, doubled
+ // among the populations directly affected.
+ pillage: 6,
+};
+
// A campaign costs nothing to start: it charges `defaultHourlyCost` for as long
// as it runs, like any other upkeep and shown in the budget. The player may
// raise or lower the spending up to `maxHourlyCost`; the opinion shift scales
@@ -36,16 +64,20 @@ export const APPROVAL = {
// `culturalWeight` is how strongly the two nations' cultural impacts bend the
// result.
//
-// Convincing the last few percent is the hardest: the daily shift is scaled by
+// Convincing the last few points is the hardest: the daily shift is scaled by
// how much room is left before the opinion reaches its maximum (warming) or
// minimum (cooling), raised to `difficultyExponent`. The rate is unchanged at
// or below the neutral midpoint, so only the final approach to a devoted or
// hateful population suffers the diminishing returns that make the last
// percentage points disproportionately expensive.
+//
+// Opinions otherwise drift one point an hour toward neutral (0.12 a day), so a
+// campaign must out-spend that drift to change minds; `dailyDelta` is set well
+// above it.
export const PROPAGANDA = {
defaultHourlyCost: 8_000,
maxHourlyCost: 40_000,
- dailyDelta: 0.012,
+ dailyDelta: 0.18,
culturalWeight: 0.6,
difficultyExponent: 2,
};
@@ -58,7 +90,9 @@ export const CAMPAIGN_COOL = -1;
// Minority policies a government may proclaim. Any number may be active at
// once, but the same policy may not be enacted twice for the same ethnicity.
// `targetEffect` and `politicalCost` are the estimates the wizard shows; the
-// model applies them through POLICY_OPINION below.
+// model applies them through POLICY_OPINION below. Effects are stated in
+// popularity points (the integer unit the model works in); only the final
+// approval figure is ever shown as a percentage.
export const POLICY_CHAMPION = "champion";
export const POLICY_EXPEL = "expel";
export const POLICY_PROTECT = "protect";
@@ -70,16 +104,16 @@ export const POLICIES = [
name: "Champion a minority",
description:
"Proclaim the state the protector of an ethnicity. Its people everywhere warm to you, but your own other populations resent the favouritism.",
- targetEffect: "+15% approval among the chosen people",
- politicalCost: "−8% approval among every other population in your country",
+ targetEffect: "+30 popularity among the chosen people",
+ politicalCost: "−16 popularity among every other population in your country",
},
{
id: POLICY_PROTECT,
name: "Protect a minority",
description:
"Shield an ethnicity inside your borders. It raises their view of you at home and abroad, at some cost with your other populations.",
- targetEffect: "+12% approval among the chosen people",
- politicalCost: "−4% approval among every other population in your country",
+ targetEffect: "+24 popularity among the chosen people",
+ politicalCost: "−8 popularity among every other population in your country",
},
{
id: POLICY_EXPEL,
@@ -87,7 +121,7 @@ export const POLICIES = [
description:
"Force an ethnicity out of the country. Its people, in and outside your borders, will hate you for a long time, and they will press into your neighbours.",
targetEffect: "The people begin to leave for neighbouring countries",
- politicalCost: "−40% approval among the expelled people, remembered for a generation",
+ politicalCost: "−80 popularity among the expelled people, remembered for a generation",
},
{
id: POLICY_IMMIGRATION,
@@ -95,7 +129,7 @@ export const POLICIES = [
description:
"Invite an ethnicity in. They will come from lands where their government is unpopular, but every other population in your country dislikes the influx.",
targetEffect: "The chosen people migrate into your country",
- politicalCost: "−5% approval among every other population in your country",
+ politicalCost: "−10 popularity among every other population in your country",
},
];
diff --git a/shared/data/relations.js b/shared/data/relations.js
index 0e61865..fd33d69 100644
--- a/shared/data/relations.js
+++ b/shared/data/relations.js
@@ -11,5 +11,8 @@ export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
// A government enacted a minority policy. Recorded by the model.
export const NEWS_POLICY = "policy";
+// A technology was discovered. Repeatable research levels raise these too, but
+// flagged so the news list can keep them quiet while the feed still notifies.
+export const NEWS_TECHNOLOGY = "technology";
// A player took control of a nation. Recorded by the server, not the model.
export const NEWS_JOIN = "join";
diff --git a/shared/data/technologies.js b/shared/data/technologies.js
index db862ef..099a235 100644
--- a/shared/data/technologies.js
+++ b/shared/data/technologies.js
@@ -1,13 +1,39 @@
// Research tree. A technology costs research points and may require earlier
// technologies before it can be started.
+//
+// Research is focused: a nation picks one technology, and every research point
+// it creates is applied to it, month on month, until it is discovered. A
+// `repeatable` technology may be researched again and again, each level costing
+// `repeatCostGrowth` more than the last, and unlike a discovery it raises no
+// news event (it only notifies the player).
-import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
+import {
+ EFFECT_GDP,
+ EFFECT_BUDGET_INCOME,
+ EFFECT_RESEARCH,
+ EFFECT_CULTURE,
+ EFFECT_INFANTRY_ATTACK,
+ EFFECT_INFANTRY_DEFENSE,
+ EFFECT_ARTILLERY_ATTACK,
+ EFFECT_CONSTRUCTION_SPEED,
+ EFFECT_RAIL_SPEED,
+ effect,
+} from "./effects.js";
+
+// The tabs the technology panel groups research under.
+export const TECH_THEMES = [
+ { id: "science", name: "Science" },
+ { id: "economy", name: "Economy" },
+ { id: "society", name: "Society" },
+ { id: "military", name: "Military" },
+];
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
+ theme: "science",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
@@ -16,6 +42,7 @@ export const TECHNOLOGIES = [
id: "industrial_automation",
name: "Industrial Automation",
description: "Robotics and control systems take over the factory floor, lifting national output.",
+ theme: "economy",
cost: 60,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
@@ -24,6 +51,7 @@ export const TECHNOLOGIES = [
id: "renewable_energy",
name: "Renewable Energy",
description: "Clean generation cuts waste and strengthens the public finances.",
+ theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
@@ -32,6 +60,7 @@ export const TECHNOLOGIES = [
id: "quantum_computing",
name: "Quantum Computing",
description: "Machines that reason in superposition. A step change in the pace of discovery.",
+ theme: "science",
cost: 120,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
@@ -40,12 +69,79 @@ export const TECHNOLOGIES = [
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
+ theme: "science",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
},
+
+ // Repeatable levels. Each raises a single stat by 1% of its base value.
+ {
+ id: "infantry_weapons",
+ name: "Infantry Weapons",
+ description: "Better small arms and training raise the damage infantry deal.",
+ theme: "military",
+ cost: 40,
+ repeatable: true,
+ repeatCostGrowth: 0.02,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
+ },
+ {
+ id: "body_armour",
+ name: "Body Armour",
+ description: "Personal protection raises infantry base defense.",
+ theme: "military",
+ cost: 40,
+ repeatable: true,
+ repeatCostGrowth: 0.02,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
+ },
+ {
+ id: "artillery_shells",
+ name: "Artillery Shells",
+ description: "Improved propellant and shells raise the damage artillery deal.",
+ theme: "military",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.02,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
+ },
+ {
+ id: "construction_technology",
+ name: "Construction Technology",
+ description: "Better methods and machines speed every building and unit the nation produces.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.02,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
+ },
+ {
+ id: "trains_technology",
+ name: "Trains Technology",
+ description: "Faster locomotives raise movement speed along railways.",
+ theme: "economy",
+ cost: 50,
+ repeatable: true,
+ repeatCostGrowth: 0.02,
+ prerequisites: ["scientific_method"],
+ effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
+ },
];
export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
+
+// The cost of the next level of a technology given how many times it has been
+// researched already. Non-repeatable technologies cost the same either way.
+export function technologyCost(proto, repeatCount = 0) {
+ if (!proto) return 0;
+ if (!proto.repeatable) return proto.cost;
+ const growth = proto.repeatCostGrowth || 0;
+ return proto.cost * Math.pow(1 + growth, Math.max(0, repeatCount));
+}
diff --git a/shared/data/units.js b/shared/data/units.js
index 4ad2294..1e87fca 100644
--- a/shared/data/units.js
+++ b/shared/data/units.js
@@ -14,6 +14,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.5,
military: true,
+ // Marks the unit for the infantry research effects (weapons, body armour).
+ infantry: true,
attack: 100,
defense: 40,
range: 1,
@@ -44,6 +46,8 @@ export const PROTO_UNITS = [
moveable: true,
speed: 0.25,
military: true,
+ // Marks the unit for the artillery research effect.
+ artillery: true,
attack: 250,
// Barely armoured: nearly every hit gets through.
defense: 5,
@@ -109,10 +113,52 @@ export const PROTO_UNITS = [
attack: 400,
defense: 120,
range: 2,
+ naval: true,
traversableTerrains: ["Sea"],
requiresBuilding: "shipyard",
icon: "icon_battleship.svg",
},
+ {
+ id: "mobile_radar",
+ name: "Mobile radar",
+ maxHp: 200,
+ cost: 50_000_000,
+ upkeep: 5000,
+ moveable: true,
+ speed: 0.5,
+ military: false,
+ attack: 0,
+ defense: 0,
+ range: 0,
+ // Watches four tiles out, cannot fight, is taken by any unit that walks
+ // onto it, and a lone one is destroyed outright by a single ranged hit.
+ vision: 4,
+ capturable: true,
+ destroyedByRangedAlone: true,
+ traversableTerrains: ["Land"],
+ icon: "icon_radar.svg",
+ },
+ {
+ id: "launch",
+ name: "Launch",
+ maxHp: 150,
+ cost: 20_000_000,
+ upkeep: 2000,
+ moveable: true,
+ speed: 0.5,
+ military: false,
+ attack: 0,
+ defense: 0,
+ range: 0,
+ // Transports one infantry unit and is defenceless on its own.
+ transportCapacity: 1,
+ transportKind: "infantry",
+ capturable: true,
+ naval: true,
+ traversableTerrains: ["Sea"],
+ requiresBuilding: "port",
+ icon: "icon_launch.svg",
+ },
];
// Only units in this list can be trained from the city screen.
@@ -122,6 +168,8 @@ export const TRAINABLE_UNIT_IDS = [
"jet_fighter",
"bomber",
"battleship",
+ "mobile_radar",
+ "launch",
];
export function protoUnitById(id) {
diff --git a/shared/game_state.js b/shared/game_state.js
index dfd2117..2ad1b33 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -44,6 +44,8 @@ import { politicsMethods } from "./game_state/politics.js";
import { orderMethods } from "./game_state/orders.js";
import { visibilityMethods } from "./game_state/visibility.js";
import { serializationMethods } from "./game_state/serialization.js";
+import { monthlyMethods } from "./game_state/monthly.js";
+import { warfareMethods } from "./game_state/warfare.js";
export {
MAX_NEWS,
@@ -104,6 +106,15 @@ export class GameState {
this.roads = new Set();
// Tiles carrying a railway. A tile holds a road or a railway, never both.
this.railways = new Set();
+ // War-time tile works: trenches dug by infantry, and military improvements
+ // (fortifications, radar towers, coastal cannons) with their owner and
+ // remaining hit points. A tile works queue per civilisation advances them.
+ this.trenches = new Set();
+ this.tileImprovements = new Map();
+ this.tileImprovementOwner = new Map();
+ this.tileImprovementHp = new Map();
+ this.tileWorks = new Map();
+ this._tileImprovementVersion = 0;
// Bumped whenever the transport network changes, so the serialised road and
// railway lists are rebuilt only when something actually moved.
this._improvementVersion = 0;
@@ -116,10 +127,18 @@ export class GameState {
this.tileBattleRecoveryStart = new Map();
this.budgets = new Map();
this.training = new Map();
- this.research = new Map();
this.culture = new Map();
this.government = new Map();
this.researched = new Map();
+ // Focused research: each nation works on one technology at a time and every
+ // point it creates flows straight into that technology. There is no bank: a
+ // nation with no focus simply does not advance. `techProgress` keeps the
+ // points committed to every technology the nation has started, so switching
+ // focus never loses work already done, and `repeatCounts` counts how many
+ // times each repeatable technology has been researched.
+ this.focusTechnology = new Map();
+ this.techProgress = new Map();
+ this.repeatCounts = new Map();
this.explored = new Map();
this.visible = new Map();
// Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
@@ -176,6 +195,9 @@ export class GameState {
this._cityAdjacent = new Map();
this._territoryByCiv = new Map();
this._gdpPerCapitaCache = new Map();
+ // Memoised nation-wide modifier totals, invalidated by _touchModifiers.
+ this._civModifierCache = new Map();
+ this._modifiersVersion = 0;
// Per-civ factors that map generation picked so every nation starts with
// the same population and GDP per capita (see _balanceStartingEconomy).
this._populationBalance = new Map();
@@ -184,6 +206,11 @@ export class GameState {
this._serializedExplored = new Map();
this._neighbourCache = new Map();
this._visibilityDirty = true;
+ // Month-over-month baseline for the nation summary bar; null until the
+ // world exists and January 2000 has a month to compare against.
+ this.monthlyStats = null;
+ this._monthIndexNow = 0;
+ this._currentMonthStats = null;
}
onChanged(fn) {
@@ -205,10 +232,12 @@ export class GameState {
this._resetCollections();
for (let i = 0; i < this.civilisations.length; i++) {
- this.research.set(i, 0);
this.culture.set(i, 0);
this.government.set(i, 0);
this.researched.set(i, new Set());
+ this.focusTechnology.set(i, -1);
+ this.techProgress.set(i, new Map());
+ this.repeatCounts.set(i, new Map());
}
this.totalHours = 0;
this._configured = true;
@@ -217,6 +246,7 @@ export class GameState {
this._refreshStatuses();
this._revealAll();
this._refreshVisibility();
+ this._initMonthly();
this._visibilityDirty = false;
this._emitChanged();
}
@@ -267,10 +297,13 @@ export class GameState {
tickHour() {
this.totalHours += 1;
+ this._tickMonthly();
this._tickTraining();
this._tickAirUnits();
this._tickStrikes();
this._tickGarrisonHealing();
+ // Popularity drifts toward neutral every hour, not just daily.
+ this._tickOpinionDrift();
if (this._visibilityDirty) {
this._refreshVisibility();
this._visibilityDirty = false;
@@ -286,6 +319,9 @@ export class GameState {
this._resolveCombatRound();
}
this._tickBuildings();
+ this._tickTileWorks();
+ this._tickEmbark();
+ this._tickCoastalCannons();
// A unit no longer sharing a tile with an enemy is not an invader any more.
for (const unit of this.units) {
if (unit.invader && !this._hasHostileOn(unit.coords, unit.civ)) unit.invader = false;
@@ -309,5 +345,7 @@ Object.assign(
politicsMethods,
orderMethods,
visibilityMethods,
- serializationMethods
+ serializationMethods,
+ monthlyMethods,
+ warfareMethods
);
diff --git a/shared/game_state/air.js b/shared/game_state/air.js
index 1cb253c..78867bf 100644
--- a/shared/game_state/air.js
+++ b/shared/game_state/air.js
@@ -3,7 +3,7 @@
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
-import { AIR_STRIKE, BOMBARD } from "../data/combat.js";
+import { AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
@@ -164,14 +164,77 @@ export const airMethods = {
// ----------------------------------------------------------- strike --
_strikeTargetAt(coords, civ) {
- const enemies = this.unitsAt(coords).filter(
- (unit) => unit.civ !== civ && this._isMilitary(unit)
- );
+ const enemies = this.unitsAt(coords).filter((unit) => {
+ if (unit.civ === civ) return false;
+ // Support units (mobile radar, launches) are legitimate targets for
+ // ranged fire even though they never fight back.
+ const proto = this.unitProto(unit);
+ return this._isMilitary(unit) || (proto && proto.capturable);
+ });
if (enemies.length === 0) return null;
- enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
+ // Infantry shelters the rest of a stack, so exposed support units are hit
+ // before dug-in troops.
+ enemies.sort((a, b) => {
+ const aExposed = this._isExposedTarget(a) ? 0 : 1;
+ const bExposed = this._isExposedTarget(b) ? 0 : 1;
+ return aExposed - bExposed || (a.hp - b.hp) || (a.id - b.id);
+ });
return enemies[0];
},
+ _isExposedTarget(unit) {
+ const proto = this.unitProto(unit);
+ return !!(proto && (proto.capturable || proto.artillery));
+ },
+
+ // An enemy city at the tile, the target of a bombardment when its garrison
+ // has already fallen.
+ _strikeCityAt(coords, civ) {
+ const city = this.cityAt(coords);
+ if (!city || city.civ === civ) return null;
+ return city;
+ },
+
+ // Ranged fire on a city with no garrison left: it kills people and leaves the
+ // usual battle scar, and the tile's structures take a share of the blow.
+ _bombardCity(city, amount) {
+ const k = key(city.coords.x, city.coords.y);
+ const population = this.tilePopulation.get(k) || 0;
+ const removed = Math.min(population, amount * CITY_BOMBARD.populationPerDamage);
+ if (removed > 0) {
+ this.tilePopulation.set(k, population - removed);
+ this._populationVersion += 1;
+ }
+ const perCapita = this.getTileGdpPerCapita(city.coords);
+ if (perCapita > 0) {
+ const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + perCapita * 0.1;
+ this.tileBattleGdpDeficit.set(k, deficit);
+ this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
+ this._gdpPerCapitaCache.delete(k);
+ }
+ this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
+ this._emitChanged();
+ },
+
+ // Applies one ranged blow to a target unit, with the special fates of exposed
+ // support units, and passes a tenth of the damage on to any structures on the
+ // tile.
+ _applyStrikeDamage(target, damage, attackerProto = null) {
+ const proto = this.unitProto(target);
+ const alone = this.unitsAt(target.coords).length <= 1;
+ if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
+ else if (proto && proto.naval && proto.capturable && alone) damage = target.hp;
+ target.hp -= damage;
+ // The tile's structures always take a tenth of an artillery or aerial blow.
+ this._damageTileImprovement(target.coords, Math.max(1, Math.round(damage * 0.1)));
+ if (target.hp > 0) return false;
+ target.hp = 0;
+ this._recordMilitaryCasualties([target]);
+ this._destroyUnit(target);
+ this._visibilityDirty = true;
+ return true;
+ },
+
// How far a unit can reach: an aircraft's mission radius, a ground battery's
// weapon range.
_strikeRange(proto) {
@@ -226,9 +289,10 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
- if (!target) return false;
+ const city = target ? null : this._strikeCityAt(goal, civ);
+ if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
- this._declareWar(civ, target.civ);
+ this._declareWar(civ, target ? target.civ : city.civ);
for (const unit of units) {
this._stopUnit(unit);
unit.strikeTarget = { x: goal.x, y: goal.y };
@@ -245,7 +309,8 @@ export const airMethods = {
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
- if (!target) return false;
+ const city = target ? null : this._strikeCityAt(goal, civ);
+ if (!target && !city) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
// `_strikeReady` above already proved the round trip fits the fuel; this
@@ -258,7 +323,7 @@ export const airMethods = {
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
- this._declareWar(civ, target.civ);
+ this._declareWar(civ, target ? target.civ : city.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
unit.pathIndex = 0;
@@ -283,12 +348,11 @@ export const airMethods = {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
- target.hp -= damage;
- if (target.hp <= 0) {
- target.hp = 0;
- this._recordMilitaryCasualties([target]);
- this._destroyUnit(target);
- this._visibilityDirty = true;
+ this._applyStrikeDamage(target, damage, this.unitProto(unit));
+ } else {
+ const city = this._strikeCityAt(goal, unit.civ);
+ if (city) {
+ this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
}
}
}
@@ -305,26 +369,29 @@ export const airMethods = {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
- if (!target || !this._strikeReady(unit, goal)) {
+ if (!target) {
+ // No garrison left: the battery shells the city itself.
+ const city = this._strikeCityAt(goal, unit.civ);
+ if (city && this._strikeReady(unit, goal)) {
+ this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
+ } else {
+ unit.strikeTarget = null;
+ }
+ continue;
+ }
+ if (!this._strikeReady(unit, goal)) {
unit.strikeTarget = null;
continue;
}
- const full = this._unitAttack(unit) - this._effectiveDefense(target, true);
+ const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [target, amount] of damage) {
- target.hp -= amount;
- if (target.hp <= 0) {
- target.hp = 0;
- destroyed.push(target);
- }
+ if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target);
}
- this._recordMilitaryCasualties(destroyed);
- for (const unit of destroyed) this._destroyUnit(unit);
- if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js
index 0cde00f..2f9d9dd 100644
--- a/shared/game_state/combat.js
+++ b/shared/game_state/combat.js
@@ -3,6 +3,12 @@
import { key } from "../hex.js";
import { BATTLE } from "../data/combat.js";
+import { POPULARITY } from "../data/politics.js";
+import {
+ EFFECT_INFANTRY_ATTACK,
+ EFFECT_INFANTRY_DEFENSE,
+ EFFECT_ARTILLERY_ATTACK,
+} from "../data/effects.js";
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
import { defenseMultiplier } from "../rules.js";
import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js";
@@ -17,14 +23,30 @@ export const combatMethods = {
return !!(proto && proto.military);
},
+ // Base attack, lifted by the nation's infantry-weapons or artillery-shells
+ // research. Only the relevant stat for the unit's type applies.
_unitAttack(unit) {
const proto = this.unitProto(unit);
- return proto && typeof proto.attack === "number" ? proto.attack : 0;
+ let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
+ if (proto && (proto.infantry || proto.artillery)) {
+ const modifiers = this.getCivModifiers(unit.civ);
+ const bonus = proto.infantry
+ ? modifiers[EFFECT_INFANTRY_ATTACK]
+ : modifiers[EFFECT_ARTILLERY_ATTACK];
+ attack *= 1 + (bonus || 0);
+ }
+ return attack;
},
+ // Base defense, lifted by the nation's body-armour research for infantry.
_unitDefense(unit) {
const proto = this.unitProto(unit);
- return proto && typeof proto.defense === "number" ? proto.defense : 0;
+ let defense = proto && typeof proto.defense === "number" ? proto.defense : 0;
+ if (proto && proto.infantry) {
+ const bonus = this.getCivModifiers(unit.civ)[EFFECT_INFANTRY_DEFENSE] || 0;
+ defense *= 1 + bonus;
+ }
+ return defense;
},
// Fraction of extra defense granted by the tile a unit occupies (terrain
@@ -34,12 +56,14 @@ export const combatMethods = {
return defenseMultiplier(tile, !!this.cityAt(coords));
},
- // A unit's base defense, boosted by its tile, and doubled when it is the
- // defending side of the exchange.
- _effectiveDefense(unit, isDefender) {
+ // A unit's base defense, boosted by its tile and any fortifications or
+ // trenches there, and doubled when it is the defending side of the exchange.
+ // The attacker's type decides whether trench and fort bonuses use their melee
+ // or artillery figures.
+ _effectiveDefense(unit, isDefender, attackerProto = null) {
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
- return defense;
+ return defense * this._tileFortificationMultiplier(unit.coords, attackerProto);
},
// Unique attacker/defender pairs. A pair is two hostile military units on the
@@ -71,7 +95,13 @@ export const combatMethods = {
!this._isAirUnit(other)
);
if (enemies.length === 0) return null;
- enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
+ // Damage lands on infantry first, then the other fighting units, so a stack
+ // soaks blows with its footsoldiers before exposing support troops.
+ const rank = (unit) => {
+ const proto = this.unitProto(unit);
+ return proto && proto.infantry ? 0 : 1;
+ };
+ enemies.sort((a, b) => (rank(a) - rank(b)) || (a.hp - b.hp) || (a.id - b.id));
return enemies[0];
},
@@ -171,12 +201,14 @@ export const combatMethods = {
for (const { attacker, defender } of this._combatPairs()) {
const toDefender = Math.max(
1,
- this._unitAttack(attacker) - this._effectiveDefense(defender, true)
+ this._unitAttack(attacker) -
+ this._effectiveDefense(defender, true, this.unitProto(attacker))
);
damage.set(defender, (damage.get(defender) || 0) + toDefender);
const toAttacker = Math.max(
1,
- this._unitAttack(defender) - this._effectiveDefense(attacker, false)
+ this._unitAttack(defender) -
+ this._effectiveDefense(attacker, false, this.unitProto(defender))
);
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
}
@@ -206,6 +238,9 @@ export const combatMethods = {
for (const unit of this.units) {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
const coords = unit.coords;
+ // Support units and unaccompanied artillery are taken from underfoot.
+ this._captureSupportUnits(unit);
+ this._captureTileImprovement(coords, unit.civ);
if (this._hasHostileOn(coords, unit.civ)) continue;
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (!city || city.civ === unit.civ || claimed.has(city.id)) continue;
@@ -214,6 +249,56 @@ export const combatMethods = {
}
},
+ // An enemy mobile radar, launch or unaccompanied artillery on the captor's
+ // tile changes hands, or is scuttled when the captor is a warship. Artillery
+ // escapes capture while any of its own infantry stands beside it.
+ _captureSupportUnits(captor) {
+ if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
+ const captorProto = this.unitProto(captor);
+ const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
+ if (enemies.length === 0) return false;
+ const enemyInfantry = enemies.some((unit) => {
+ const proto = this.unitProto(unit);
+ return proto && proto.infantry;
+ });
+ let changed = false;
+ for (const enemy of enemies) {
+ const proto = this.unitProto(enemy);
+ if (!proto) continue;
+ if (proto.capturable) {
+ // A warship over a launch stack simply sinks it, unprotected as it is.
+ if (proto.naval && captorProto && captorProto.naval) {
+ this._destroyUnit(enemy);
+ } else {
+ enemy.civ = captor.civ;
+ enemy.strikeTarget = null;
+ }
+ changed = true;
+ } else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
+ enemy.civ = captor.civ;
+ enemy.strikeTarget = null;
+ changed = true;
+ }
+ }
+ if (changed) {
+ this._visibilityDirty = true;
+ this._emitChanged();
+ }
+ return changed;
+ },
+
+ // Enemy aircraft caught on a captured city's airfield change allegiance.
+ _captureCityAircraft(city, newCiv) {
+ for (const unit of this.unitsAt(city.coords)) {
+ const proto = this.unitProto(unit);
+ if (!proto || !proto.air || unit.civ === newCiv) continue;
+ unit.civ = newCiv;
+ unit.homeCityId = city.id;
+ unit.strikeTarget = null;
+ }
+ this._visibilityDirty = true;
+ },
+
_destroyUnit(unit) {
const index = this.units.indexOf(unit);
if (index >= 0) this.units.splice(index, 1);
@@ -223,6 +308,14 @@ export const combatMethods = {
// Called when a unit finishes stepping onto a tile. A military unit entering
// an enemy city captures it, but only once every defender has fallen.
_onUnitArrived(unit) {
+ // Walking onto a defenceless support unit or unaccompanied artillery takes
+ // it, even while other enemies hold the tile.
+ this._captureSupportUnits(unit);
+ // A military unit also captures a defenceless enemy improvement underfoot,
+ // such as a radar tower, whether or not a fight is still going on.
+ if (this._isMilitary(unit) && !this._isAirUnit(unit)) {
+ this._captureTileImprovement(unit.coords, unit.civ);
+ }
if (this._hasHostileOn(unit.coords, unit.civ)) return;
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
@@ -247,6 +340,10 @@ export const combatMethods = {
}
}
for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1);
+ // Losing a unit costs the owner standing at home; every enemy that was
+ // there to see it gains.
+ this._shiftOwnPopularity(unit.civ, -POPULARITY.unitLoss);
+ for (const enemy of enemies) this._shiftOwnPopularity(enemy, POPULARITY.unitKill);
}
},
@@ -289,6 +386,8 @@ export const combatMethods = {
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
);
}
+ // Fortifications holding a battlefield are battered by the fighting.
+ this._damageTileImprovement(battle.coords, BATTLE.structureDamagePerDay);
}
},
diff --git a/shared/game_state/constants.js b/shared/game_state/constants.js
index 95c9487..bf58c03 100644
--- a/shared/game_state/constants.js
+++ b/shared/game_state/constants.js
@@ -7,6 +7,20 @@ export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
+
+// Hit points of the military tile improvements, so they can be worn down by
+// ranged fire before being knocked out.
+export const TILE_IMPROVEMENT_HP = {
+ fortifications: 2000,
+ radar_tower: 600,
+ coastal_cannon: 800,
+};
+
+// A coastal cannon's hourly volley against a hostile warship.
+export const COASTAL_CANNON = { damage: 120 };
+
+// How long infantry take to board a launch.
+export const EMBARK_HOURS = 24;
// The calendar lives in one place: re-exported so model code can keep
// importing every simulation constant from this module.
export { HOURS_PER_DAY, DAYS_PER_YEAR, HOURS_PER_YEAR } from "../game_clock.js";
diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js
index 1b4380b..6165655 100644
--- a/shared/game_state/economy.js
+++ b/shared/game_state/economy.js
@@ -7,8 +7,12 @@ import {
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
+ EFFECT_CONSTRUCTION_SPEED,
+ EFFECT_RAIL_SPEED,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
+import { technologyCost, technologyById } from "../data/technologies.js";
+import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
gdpPerCapitaFactors,
@@ -74,6 +78,7 @@ export const economyMethods = {
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,
+ trench: this.trenches.has(k),
regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
regionalLabel: region ? region.name : null,
nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
@@ -103,8 +108,10 @@ export const economyMethods = {
}
// Running propaganda campaigns are upkeep too, paid hour by hour.
total += this.getCampaignUpkeep(civ);
- // The transport network costs an hourly maintenance per tile held.
+ // The transport network and military tile improvements cost an hourly
+ // maintenance per tile and structure held.
total += this.getTransportUpkeep(civ);
+ total += this.getTileImprovementUpkeep(civ);
return total;
},
@@ -251,13 +258,21 @@ export const economyMethods = {
const campaignUpkeep = campaignSources.reduce((sum, source) => sum + source.amount, 0);
const transportSources = this._transportUpkeepSources(civ);
const transportUpkeep = transportSources.reduce((sum, source) => sum + source.amount, 0);
+ const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
+ const tileImprovementUpkeep =
+ tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
return {
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
unitUpkeep: { total: unitUpkeep, sources: unitSources, groups: unitSources },
campaignUpkeep: { total: campaignUpkeep, sources: campaignSources, groups: campaignSources },
transportUpkeep: { total: transportUpkeep, sources: transportSources, groups: transportSources },
- net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep,
+ tileUpkeep: {
+ total: tileImprovementUpkeep,
+ sources: tileImprovementSources,
+ groups: tileImprovementSources,
+ },
+ net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
@@ -468,19 +483,40 @@ export const economyMethods = {
return totals;
},
+ // Nation-wide modifier totals, memoised because pathfinding reads the rail
+ // speed for every tile it expands and rebuilding the totals there is costly.
getCivModifiers(civ) {
+ const cached = this._civModifierCache.get(civ);
+ if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = this.getCivBuildingModifiers(civ);
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
+ this._civModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
- // ------------------------------------------- research and culture --
-
- getResearch(civ) {
- return this.research.get(civ) || 0;
+ // Invalidates the memoised modifier totals after anything that shapes them
+ // changes: research, buildings or government.
+ _touchModifiers() {
+ this._modifiersVersion += 1;
},
+ // Construction and training take this much longer than the base time; a
+ // construction technology level of 1% shortens it by 1%.
+ constructionSpeedMultiplier(civ) {
+ const bonus = this.getCivModifiers(civ)[EFFECT_CONSTRUCTION_SPEED] || 0;
+ return 1 / (1 + Math.max(-0.99, bonus));
+ },
+
+ // The time multiplier for travelling a railway: a trains technology level of
+ // 1% moves 1% faster along rail.
+ railSpeedMultiplier(civ) {
+ const bonus = this.getCivModifiers(civ)[EFFECT_RAIL_SPEED] || 0;
+ return 1 / (1 + Math.max(-0.99, bonus));
+ },
+
+ // ------------------------------------------- research and culture --
+
getCulture(civ) {
return this.culture.get(civ) || 0;
},
@@ -508,23 +544,128 @@ export const economyMethods = {
canResearchTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.technologies.length) return false;
- if (this.isResearched(civ, index)) return false;
- return prerequisitesMet(
- this.technologies[index],
- this.researched.get(civ) || new Set(),
- this.technologies
- );
+ const tech = this.technologies[index];
+ // A repeatable technology may be researched again; any other is done once.
+ if (!tech.repeatable && this.isResearched(civ, index)) return false;
+ return prerequisitesMet(tech, this.researched.get(civ) || new Set(), this.technologies);
+ },
+
+ // The technology a nation is currently focusing its research on, or -1.
+ getFocusedTechnology(civ) {
+ const index = this.focusTechnology.get(civ);
+ return index === undefined ? -1 : index;
+ },
+
+ // The research points already committed to a technology. With no index the
+ // current focus's progress is returned, which is what the panel usually wants.
+ getTechnologyProgress(civ, index = this.getFocusedTechnology(civ)) {
+ if (index < 0) return 0;
+ const map = this.techProgress.get(civ);
+ return map ? map.get(index) || 0 : 0;
+ },
+
+ // How many times a repeatable technology has been researched.
+ getTechnologyRepeatCount(civ, index) {
+ const tech = this.technologies[index];
+ if (!tech) return 0;
+ const counts = this.repeatCounts.get(civ);
+ return counts ? counts.get(tech.id) || 0 : 0;
+ },
+
+ // The cost of the next level of a technology, growing 2% each repeat.
+ getTechnologyCost(civ, index) {
+ return technologyCost(this.technologies[index], this.getTechnologyRepeatCount(civ, index));
},
getCivTechnologyModifiers(civ) {
const totals = {};
- const set = this.researched.get(civ) || new Set();
- for (const index of set) {
- mergeModifiers(totals, technologyEffects(this.technologies[index]));
+ const civResearch = this.researched.get(civ) || new Set();
+ for (const index of civResearch) {
+ const tech = this.technologies[index];
+ if (!tech || tech.repeatable) continue;
+ mergeModifiers(totals, technologyEffects(tech));
+ }
+ // Repeatable technologies stack their effect once per researched level.
+ const counts = this.repeatCounts.get(civ);
+ if (counts) {
+ for (const [id, count] of counts) {
+ const tech = technologyById(id);
+ if (!tech || count <= 0) continue;
+ for (const e of tech.effects) {
+ totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, count);
+ }
+ }
}
return totals;
},
+ // -------------------------------------------------------- research --
+
+ // Points a nation creates flow straight into its focused technology; there is
+ // no bank, so time spent with nothing focused is simply time not advancing.
+ _addResearch(civ, amount) {
+ if (!(amount > 0)) return;
+ const index = this.getFocusedTechnology(civ);
+ if (index < 0) return;
+ this._applyTechnologyProgress(civ, index, this.getTechnologyProgress(civ, index) + amount);
+ },
+
+ // Records the points committed to one technology.
+ _setTechnologyProgress(civ, index, progress) {
+ let map = this.techProgress.get(civ);
+ if (!map) {
+ map = new Map();
+ this.techProgress.set(civ, map);
+ }
+ if (progress > 0) map.set(index, progress);
+ else map.delete(index);
+ },
+
+ // Commits `progress` points to a technology. Any overflow past its cost is
+ // discarded: nothing is carried over to whatever is focused next.
+ _applyTechnologyProgress(civ, index, progress) {
+ const cost = this.getTechnologyCost(civ, index);
+ if (progress < cost) {
+ this._setTechnologyProgress(civ, index, progress);
+ return;
+ }
+ this._discoverTechnology(civ, index);
+ // The completed level spends its points; a repeatable starts the next level
+ // from zero. Other technologies keep whatever they had banked.
+ this._setTechnologyProgress(civ, index, 0);
+ this._clearFocus(civ);
+ this._gdpPerCapitaCache.clear();
+ this._emitChanged();
+ },
+
+ // Clears the focus only: progress already committed to any technology stays
+ // put, so switching focus and coming back resumes where the nation left off.
+ _clearFocus(civ) {
+ this.focusTechnology.set(civ, -1);
+ },
+
+ // Marks a technology known. A repeatable one instead counts one more level;
+ // its notice is flagged so the news list can stay quiet while the feed still
+ // tells the player.
+ _discoverTechnology(civ, index) {
+ const tech = this.technologies[index];
+ if (!tech) return;
+ if (tech.repeatable) {
+ const counts = this.repeatCounts.get(civ);
+ if (counts) counts.set(tech.id, (counts.get(tech.id) || 0) + 1);
+ } else {
+ this.researched.get(civ).add(index);
+ }
+ this._touchModifiers();
+ this._addNews({
+ type: NEWS_TECHNOLOGY,
+ civ,
+ technology: tech.id,
+ name: tech.name,
+ repeatable: !!tech.repeatable,
+ });
+ },
+
getBudget(civ) {
return this.budgets.has(civ) ? this.budgets.get(civ) : 0;
},
@@ -568,7 +709,7 @@ export const economyMethods = {
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
- this.research.set(civ, this.getResearch(civ) + scienceRate);
+ this._addResearch(civ, scienceRate);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
@@ -628,6 +769,7 @@ export const economyMethods = {
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
},
// Units resting inside one of their own cities recover a little health each
diff --git a/shared/game_state/monthly.js b/shared/game_state/monthly.js
new file mode 100644
index 0000000..3aaa965
--- /dev/null
+++ b/shared/game_state/monthly.js
@@ -0,0 +1,49 @@
+// Month-over-month tracking for the nation summary bar. Once a calendar month
+// rolls over, the nation figures captured at the start of the month that just
+// ended become the baseline the current month is compared against, so every
+// indicator can show whether it rose or fell since "the past month". January
+// 2000 has no baseline, so it carries no change indicator.
+
+import { unixTime } from "../game_clock.js";
+
+export const monthlyMethods = {
+ // The absolute month number of an in-game hour, so a rollover is a simple
+ // comparison even across a year boundary.
+ _monthIndexOf(totalHours) {
+ const date = new Date(unixTime(totalHours) * 1000);
+ return date.getUTCFullYear() * 12 + date.getUTCMonth();
+ },
+
+ // Captures the starting month once the world exists.
+ _initMonthly() {
+ this._monthIndexNow = this._monthIndexOf(this.totalHours);
+ this._currentMonthStats = this._captureMonthlyStats();
+ this.monthlyStats = null;
+ },
+
+ // Called every hour; only a calendar-month change does any work.
+ _tickMonthly() {
+ const index = this._monthIndexOf(this.totalHours);
+ if (index === this._monthIndexNow) return;
+ // What was current at the start of the month that just ended is now the
+ // baseline for the new month.
+ this.monthlyStats = this._currentMonthStats;
+ this._currentMonthStats = this._captureMonthlyStats();
+ this._monthIndexNow = index;
+ },
+
+ // One record per civilisation of the indicators the summary bar compares.
+ _captureMonthlyStats() {
+ return this.civilisations.map((_, civ) => {
+ const headline = this._civHeadline(civ);
+ return {
+ population: headline.population,
+ gdp: headline.gdp,
+ gdpPerCapita: headline.gdpPerCapita,
+ popularity: this.getCivPopularity(civ),
+ approval: this.getCivApproval(civ),
+ budget: this.getBudget(civ),
+ };
+ });
+ },
+};
diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js
index 3d8e423..8f67767 100644
--- a/shared/game_state/movement.js
+++ b/shared/game_state/movement.js
@@ -316,16 +316,20 @@ export const movementMethods = {
return 1 / (unit.moveSpeed || this._unitSpeed(unit));
}
// A road or railway levels the terrain: every such tile costs the same to
- // cross, a railway half again as cheap as a road.
- return this._tileMovementMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
+ // cross, a railway half again as cheap as a road, and a nation's trains
+ // technology shortens its own railways further.
+ return this._tileMovementMultiplier(coords, unit.civ) / this._effectiveSpeedAt(unit, coords);
},
// The cheapest transport multiplier a tile offers: its railway, else its road,
// else the terrain's own. Flat across the whole tile, so a road levels hills
- // and forests alike.
- _tileMovementMultiplier(coords) {
+ // and forests alike. A nation's trains technology speeds up its railways only.
+ _tileMovementMultiplier(coords, civ = -1) {
const k = key(coords.x, coords.y);
- if (this.railways.has(k)) return TRANSPORT_BY_ID.railway.movementCostMultiplier;
+ if (this.railways.has(k)) {
+ const base = TRANSPORT_BY_ID.railway.movementCostMultiplier;
+ return civ >= 0 ? base * this.railSpeedMultiplier(civ) : base;
+ }
if (this.roads.has(k)) return TRANSPORT_BY_ID.road.movementCostMultiplier;
return this.terrainStats.movementCostMultiplier(coords);
},
@@ -333,11 +337,14 @@ export const movementMethods = {
_fastestTileTravelHours(unit) {
// A railway (or, failing that, a road) is the cheapest possible tile, so the
// heuristic must use it when it beats the most generous terrain or A* could
- // overestimate and miss the cheapest route.
+ // overestimate and miss the cheapest route. A railway can be cheaper still
+ // for a nation with trains technology.
+ const railway = TRANSPORT_BY_ID.railway.movementCostMultiplier *
+ this.railSpeedMultiplier(unit.civ);
const fastest = Math.min(
this._minMovementCost,
TRANSPORT_BY_ID.road.movementCostMultiplier,
- TRANSPORT_BY_ID.railway.movementCostMultiplier
+ railway
);
return fastest / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
},
@@ -377,7 +384,7 @@ export const movementMethods = {
// Without it every candidate tile scans the whole army; the search therefore
// looks up occupancy in constant time, exactly as the browser's prediction
// does in map_view/motion.js.
- _canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null) {
+ _canUnitEnter(unit, coords, isGoal = false, targetCiv = -1, occupancy = null, zoc = null, from = null) {
const proto = this.unitProto(unit);
const air = this._isAirProto(proto);
if (air) {
@@ -388,6 +395,14 @@ export const movementMethods = {
} else if (!this.canEnter(unit.civ, proto, coords)) {
return false;
}
+ // Zone of control: a hostile unit denies its own tile and the six around it.
+ // Troops may enter or leave the ring, but never cross straight from one
+ // denied tile into another. A deliberate attack on the goal is exempt, so
+ // the ring never shields a garrison from a charge.
+ if (!air && !isGoal && zoc && from &&
+ zoc.has(key(from.x, from.y)) && zoc.has(key(coords.x, coords.y))) {
+ return false;
+ }
const owner = this.civAt(coords);
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
const occupants = occupancy
@@ -429,6 +444,22 @@ export const movementMethods = {
return index;
},
+ // The tiles denied to `civ` by hostile ground forces: every hostile military
+ // unit's tile and the six around it. Aircraft project no zone.
+ _zoneOfControl(civ) {
+ const denied = new Set();
+ for (const other of this.units) {
+ if (other.civ === civ) continue;
+ if (!this._isMilitary(other) || this._isAirUnit(other)) continue;
+ const k = key(other.coords.x, other.coords.y);
+ denied.add(k);
+ for (const neighbour of this._neighbours(other.coords)) {
+ denied.add(key(neighbour.x, neighbour.y));
+ }
+ }
+ return denied;
+ },
+
// `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
@@ -441,6 +472,7 @@ export const movementMethods = {
if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey);
const occupancy = this._buildOccupancy(unit);
const targetCiv = this._targetCivFor(unit, goal, occupancy);
+ const zoc = this._zoneOfControl(unit.civ);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
@@ -448,10 +480,14 @@ export const movementMethods = {
start,
goal,
(coords) => this._neighbours(coords),
- (coords, isGoal) => {
- const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
+ (coords, isGoal, from) => {
+ const fromKey = from ? key(from.x, from.y) : "";
+ const k = `${key(coords.x, coords.y)}${isGoal ? ":goal" : ""}@${fromKey}`;
if (!enterCache.has(k)) {
- enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy));
+ enterCache.set(
+ k,
+ this._canUnitEnter(unit, coords, isGoal, targetCiv, occupancy, zoc, from)
+ );
}
return enterCache.get(k);
},
diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js
index 1ad2898..e5c9c75 100644
--- a/shared/game_state/orders.js
+++ b/shared/game_state/orders.js
@@ -45,7 +45,9 @@ export const orderMethods = {
protoIndex,
capacity,
elapsedHours: 0,
- totalHours: (proto.cost / capacity) * this.constructionApprovalMultiplier(city),
+ totalHours: (proto.cost / capacity) *
+ this.constructionApprovalMultiplier(city) *
+ this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -84,6 +86,7 @@ export const orderMethods = {
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
this._emitChanged();
return true;
}
@@ -97,6 +100,7 @@ export const orderMethods = {
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
this._emitChanged();
return true;
}
@@ -109,7 +113,9 @@ export const orderMethods = {
level,
capacity,
elapsedHours: 0,
- totalHours: (cost / capacity) * this.constructionApprovalMultiplier(city),
+ totalHours: (cost / capacity) *
+ this.constructionApprovalMultiplier(city) *
+ this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
@@ -143,6 +149,26 @@ export const orderMethods = {
return true;
},
+ // Removes whatever transport improvement stands on a land tile the
+ // civilisation controls. Removal is immediate and free, so a mis-built road
+ // can be cleared without a new one replacing it.
+ requestRemoveImprovement(civ, coords) {
+ if (civ < 0 || civ >= this.civilisations.length) return false;
+ if (!coords) return false;
+ if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
+ const k = key(coords.x, coords.y);
+ const tile = this.tiles[k];
+ if (!tile || tile.terrainClass !== "Land") return false;
+ if (this.civAt(coords) !== civ) return false;
+ const present = this.railways.has(k) || this.roads.has(k);
+ if (!present) return false;
+ this.roads.delete(k);
+ this.railways.delete(k);
+ this._improvementVersion += 1;
+ this._emitChanged();
+ return true;
+ },
+
requestDemolish(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
@@ -155,6 +181,7 @@ export const orderMethods = {
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
this._emitChanged();
return true;
},
@@ -169,17 +196,20 @@ export const orderMethods = {
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
this._emitChanged();
return true;
},
- requestResearchTechnology(civ, index) {
+ // Focuses a nation's research on one technology. Every point it creates from
+ // now on flows straight into it; nothing is charged up front and nothing is
+ // banked while no technology is focused.
+ requestFocusTechnology(civ, index) {
if (!this.canResearchTechnology(civ, index)) return false;
- const technology = this.technologies[index];
- if (this.getResearch(civ) < technology.cost) return false;
- this.research.set(civ, this.getResearch(civ) - technology.cost);
- this.researched.get(civ).add(index);
- this._gdpPerCapitaCache.clear();
+ if (this.getFocusedTechnology(civ) === index) return true;
+ // Progress is kept per technology, so switching focus never discards work
+ // already done on either the old focus or the new one.
+ this.focusTechnology.set(civ, index);
this._emitChanged();
return true;
},
diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js
index cecd616..275d853 100644
--- a/shared/game_state/politics.js
+++ b/shared/game_state/politics.js
@@ -27,9 +27,11 @@ import {
REBELLION,
WAR_DECLARATION,
CULTURE_IMPACT,
+ POPULARITY,
} from "../data/politics.js";
import { NEWS_POLICY } from "../data/relations.js";
import { EFFECT_CULTURE } from "../data/effects.js";
+import { popularityDisplay, popularityPoints } from "../rules.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
// How far from one of its cities an ethnicity can meaningfully spread. Beyond
@@ -57,7 +59,6 @@ export const politicsMethods = {
_tickPolitics() {
this._tickCampaigns();
- this._tickOpinionDrift();
this._tickMigration();
this._tickUprisings();
this._tickCultureImpact();
@@ -204,6 +205,19 @@ export const politicsMethods = {
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
},
+ // One ethnicity's share of a civilisation's whole population, 0 when it has
+ // no presence there. Used to bound expulsions.
+ getCivEthnicShare(civ, ethnicity) {
+ const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
+ let total = 0;
+ let target = 0;
+ for (const [eth, count] of counts) {
+ total += count;
+ if (eth === ethnicity) target += count;
+ }
+ return total > 0 ? target / total : 0;
+ },
+
// The ethnic makeup of one city's region.
getCityEthnicMakeup(city) {
const scale = this._populationBalance.get(city.civ) || 1;
@@ -295,8 +309,10 @@ export const politicsMethods = {
}
},
+ // Opinions slide one point an hour back toward neutral, so the rate is a
+ // constant 1 point/h and the time to neutrality is simply the points left.
_tickOpinionDrift() {
- const step = OPINION.driftPerDay;
+ const step = POPULARITY.driftPerHour * POPULARITY.opinionPerPoint;
this._driftStore(this.ethnicOpinions, step);
this._driftStore(this.govOpinions, step);
},
@@ -316,42 +332,74 @@ export const politicsMethods = {
// ------------------------------------------------------ approval --
- // A city region's approval of its government: the population-weighted mean of
- // every resident population's opinion of it.
- getCityApproval(city) {
+ // A city region's opinion of its government, in one pass: the linear
+ // population-weighted approval (which drives tax and construction) and the
+ // same figure in integer popularity points (what the player is shown).
+ getCityOpinions(city) {
const version = this._politicsVersion;
const cached = this._approvalCache.get(city.id);
- if (cached && cached.version === version) return cached.value;
- const value = this._weightedApproval(this.regionTiles(city), city.civ);
- this._approvalCache.set(city.id, { version, value });
- return value;
+ if (cached && cached.version === version) return cached;
+ const value = this._weightedOpinions(this.regionTiles(city), city.civ);
+ const record = { version, ...value };
+ this._approvalCache.set(city.id, record);
+ return record;
},
- // A nation's government approval among its own people. Memoised against the
- // politics version, like getCityApproval, since the snapshot reads it for
+ // A nation's opinion figures among its own people. Memoised against the
+ // politics version, like getCityOpinions, since the snapshot reads it for
// every nation on every broadcast.
- getCivApproval(civ) {
+ getCivOpinions(civ) {
const version = this._politicsVersion;
const cached = this._civApprovalCache.get(civ);
- if (cached && cached.version === version) return cached.value;
- const value = this._weightedApproval(this._territoryByCiv.get(civ) || [], civ);
- this._civApprovalCache.set(civ, { version, value });
- return value;
+ if (cached && cached.version === version) return cached;
+ const value = this._weightedOpinions(this._territoryByCiv.get(civ) || [], civ);
+ const record = { version, ...value };
+ this._civApprovalCache.set(civ, record);
+ return record;
},
- _weightedApproval(coordsList, government) {
+ getCityApproval(city) {
+ return this.getCityOpinions(city).approval;
+ },
+
+ getCivApproval(civ) {
+ return this.getCivOpinions(civ).approval;
+ },
+
+ getCityPopularity(city) {
+ return this.getCityOpinions(city).popularity;
+ },
+
+ getCivPopularity(civ) {
+ return this.getCivOpinions(civ).popularity;
+ },
+
+ // What the player sees, in 0..1: the popularity points through the logistic.
+ getCityPopularityDisplay(city) {
+ return popularityDisplay(this.getCityPopularity(city));
+ },
+
+ getCivPopularityDisplay(civ) {
+ return popularityDisplay(this.getCivPopularity(civ));
+ },
+
+ _weightedOpinions(coordsList, government) {
let total = 0;
+ let points = 0;
let weight = 0;
for (const coords of coordsList) {
const k = key(coords.x, coords.y);
const population = this.tilePopulation.get(k) || 0;
for (const [eth, share] of this.getTileEthnicFractions(coords)) {
const w = population * share;
- total += w * this.getGovOpinion(eth, government);
+ const opinion = this.getGovOpinion(eth, government);
+ total += w * opinion;
+ points += w * popularityPoints(opinion);
weight += w;
}
}
- return weight > 0 ? total / weight : OPINION.baseline;
+ if (weight <= 0) return { approval: OPINION.baseline, popularity: 0 };
+ return { approval: total / weight, popularity: Math.round(points / weight) };
},
// The multiplier on a city's construction time. Above the approval floor it
@@ -533,6 +581,11 @@ export const politicsMethods = {
if (!record) return false;
if (!isKnownPolicy(policyId)) return false;
if (this.hasPolicy(civ, policyId, ethnicity)) return false;
+ // A minority that makes up more than a tenth of the country cannot simply
+ // be expelled: its people are too many to drive out.
+ if (policyId === POLICY_EXPEL && this.getCivEthnicShare(civ, ethnicity) > 0.1) {
+ return false;
+ }
record.push({ id: this._nextPolicyId++, type: policyId, ethnicity });
this._applyPolicyOpinion(civ, policyId, ethnicity);
this._addNews({ type: NEWS_POLICY, civ, policy: policyId, ethnicity });
@@ -771,19 +824,54 @@ export const politicsMethods = {
this._addGrudge(observer, aggressor, -WAR_CRIME.tileOpinion * factor, until);
this._shiftEthnicOpinion(observer, aggressor, -WAR_CRIME.tileEthnicOpinion * factor);
}
+ // The world's governments also simply lose standing over the atrocity.
+ this._applyPillagePopularity(aggressor, coords);
this._touchPolitics();
},
// ------------------------------------------------ war and uprising --
// The world reacts to a declaration of war: onlookers who liked the victim
- // turn on the aggressor, while those who loathed it quietly approve.
+ // turn on the aggressor, while those who loathed it quietly approve. On top
+ // of that the aggressor's own people resent the war, and a people rallied by
+ // an unprovoked attack on a government it already loathes take heart.
_applyWarPopularity(actor, target) {
for (let observer = 0; observer < this.civilisations.length; observer++) {
const stance = this.getGovOpinion(observer, target);
const delta = -WAR_DECLARATION.penalty * (stance - OPINION.baseline) * 2;
if (delta !== 0) this._shiftGovOpinion(observer, actor, delta);
}
+ this._shiftOwnPopularity(actor, -POPULARITY.declareWar);
+ // A people already hostile to the aggressor rallies behind its own
+ // government when that aggressor attacks.
+ const aggressorStanding =
+ this._weightedOpinions(this._territoryByCiv.get(target) || [], actor).approval;
+ if (aggressorStanding < POPULARITY.declaredOnThreshold) {
+ this._shiftOwnPopularity(target, POPULARITY.declaredOnBoost);
+ }
+ this._touchPolitics();
+ },
+
+ // Move a government's standing with its own people by `points`.
+ _shiftOwnPopularity(civ, points) {
+ if (!this._validCiv(civ) || points === 0) return;
+ const delta = points * POPULARITY.opinionPerPoint;
+ const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
+ for (const eth of counts.keys()) this._shiftGovOpinion(eth, civ, delta);
+ },
+
+ // The world turns against an aggressor that pillages a tile, with the peoples
+ // directly affected feeling it twice as sharply. It is a grudge, so it fades
+ // on the same clock as the rest of the war-crime memory.
+ _applyPillagePopularity(aggressor, coords) {
+ if (!this._validCiv(aggressor)) return;
+ const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
+ const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
+ for (let observer = 0; observer < this.civilisations.length; observer++) {
+ this._addGrudge(observer, aggressor, amount, until);
+ }
+ // The tile's dominant people carry the double debuff.
+ this._addGrudge(this._dominantEthnicity(coords), aggressor, amount, until);
this._touchPolitics();
},
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index 58c9d07..5763187 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -3,6 +3,7 @@
import { parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
+import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
@@ -34,6 +35,10 @@ export const serializationMethods = {
// improvement version.
roads: this._serializeTransport(this.roads, "_serializedRoadsCache"),
railways: this._serializeTransport(this.railways, "_serializedRailwaysCache"),
+ // War-time tile works: trenches as [x, y], military improvements as
+ // [x, y, id, owner, hp].
+ trenches: this._serializeTrenches(),
+ tileImprovements: this._serializeTileImprovements(),
// Each land tile's region: [x, y, cityId], so the browser attributes a
// tile's GDP modifiers to the city that develops it.
regions: this._serializeRegions(),
@@ -45,7 +50,6 @@ export const serializationMethods = {
civStats: this._serializeCivStats(),
cityStats: this._serializeCityStats(),
budgets: this._serializeCivValues(this.budgets),
- research: this._serializeCivValues(this.research),
culture: this._serializeCivValues(this.culture),
// Each nation's share of the world's cultural output (zero-sum) and the
// people's approval of every government.
@@ -66,16 +70,28 @@ export const serializationMethods = {
},
government: this._serializeCivValues(this.government),
researched: this._serializeResearched(),
+ // Focused research: the technology each nation is working on, the points
+ // committed to every technology it has started, and how many levels of
+ // each repeatable it has.
+ focusTechnology: this._serializeCivValues(this.focusTechnology),
+ techProgress: this._serializeTechProgress(),
+ repeatCounts: this._serializeRepeatCounts(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
news: this._serializeNews(),
battles: this._serializeBattles(),
+ // Each nation's indicators a month ago, for the summary change arrows;
+ // empty during January 2000, which has no past month.
+ monthly: this.monthlyStats || [],
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, queue]) => [
cityId,
queue.map((entry) => ({ ...entry })),
])
),
+ // The military improvements each nation has under construction, so the
+ // tile panel can show a progress bar like a city's production.
+ tileWorks: this._serializeTileWorks(),
// Which of the large per-tile collections changed since the last
// broadcast, so a viewer can rebuild only those and the server can omit
// the rest from the wire (see GameServer._broadcastState).
@@ -85,6 +101,7 @@ export const serializationMethods = {
population: this._populationVersion,
ethnicity: this._ethnicityVersion,
improvements: this._improvementVersion,
+ warfare: this._tileImprovementVersion,
},
};
},
@@ -102,20 +119,28 @@ export const serializationMethods = {
if (viewerCiv < 0) {
return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
- upkeep: 0, research: 0, culture: 0, government: 0,
- approval: 0.5, cultureImpact: 0, ethnicMakeup: [],
+ upkeep: 0, researchRate: 0, culture: 0, government: 0,
+ approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
+ constructionSpeed: 0, railSpeed: 0,
breakdown: null,
};
}
const headline = this._civHeadline(viewerCiv);
+ const modifiers = this.getCivModifiers(viewerCiv);
return {
...headline,
+ // Repeatable research the browser mirrors: construction and rail speed.
+ constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
+ railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv),
- research: this.getResearch(viewerCiv),
+ // The research this nation creates each hour, all of which flows into the
+ // focused technology. There is no stock of points.
+ researchRate: modifiers[EFFECT_RESEARCH] || 0,
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
approval: this.getCivApproval(viewerCiv),
+ popularity: this.getCivPopularity(viewerCiv),
cultureImpact: this.getCulturalImpact(viewerCiv),
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
@@ -177,6 +202,11 @@ export const serializationMethods = {
stepHours,
segmentHours,
waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]),
+ // A transport's cargo and any boarding still under way.
+ cargo: (unit.cargo || []).map((passenger) => ({ ...passenger })),
+ embarking: unit.embarking
+ ? { hoursLeft: unit.embarking.hoursLeft, count: unit.embarking.ids.length }
+ : null,
};
});
},
@@ -197,6 +227,30 @@ export const serializationMethods = {
return result;
},
+ _serializeTrenches() {
+ const result = [];
+ for (const k of this.trenches) {
+ const coords = parseKey(k);
+ result.push([coords.x, coords.y]);
+ }
+ return result;
+ },
+
+ _serializeTileImprovements() {
+ const result = [];
+ for (const [k, id] of this.tileImprovements) {
+ const coords = parseKey(k);
+ result.push([
+ coords.x,
+ coords.y,
+ id,
+ this.tileImprovementOwner.get(k) ?? -1,
+ this.tileImprovementHp.get(k) || 0,
+ ]);
+ }
+ return result;
+ },
+
// A sparse [x, y, cityId] list of every tile's region, memoised against the
// region version so an unchanged world is not re-walked on every broadcast.
_serializeRegions() {
@@ -243,6 +297,7 @@ export const serializationMethods = {
return this.civilisations.map((_, index) => ({
...this._civHeadline(index),
approval: this.getCivApproval(index),
+ popularity: this.getCivPopularity(index),
cultureImpact: this.getCulturalImpact(index),
}));
},
@@ -288,6 +343,42 @@ export const serializationMethods = {
return result;
},
+ // The points each nation has committed to every technology it has started,
+ // as { technologyIndex: points } objects. Work is never lost when the focus
+ // changes, so every started technology travels in the snapshot.
+ _serializeTechProgress() {
+ const result = [];
+ for (let i = 0; i < this.civilisations.length; i++) {
+ const map = this.techProgress.get(i);
+ const record = {};
+ if (map) {
+ for (const [index, points] of map) {
+ if (points > 0) record[index] = points;
+ }
+ }
+ result.push(record);
+ }
+ return result;
+ },
+
+ // The tile improvements under construction, one array per nation, each entry
+ // { coords: [x, y], id, elapsedHours, totalHours }.
+ _serializeTileWorks() {
+ const result = [];
+ for (let i = 0; i < this.civilisations.length; i++) {
+ const queue = this.tileWorks.get(i) || [];
+ result.push(
+ queue.map((work) => ({
+ coords: [work.coords.x, work.coords.y],
+ id: work.id,
+ elapsedHours: work.elapsedHours,
+ totalHours: work.totalHours,
+ }))
+ );
+ }
+ return result;
+ },
+
_serializeResearched() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
@@ -298,6 +389,23 @@ export const serializationMethods = {
return result;
},
+ // How many times each nation has researched every repeatable technology, as
+ // { technologyId: count } objects, skipping the unused ones.
+ _serializeRepeatCounts() {
+ const result = [];
+ for (let i = 0; i < this.civilisations.length; i++) {
+ const counts = this.repeatCounts.get(i);
+ const record = {};
+ if (counts) {
+ for (const [id, count] of counts) {
+ if (count > 0) record[id] = count;
+ }
+ }
+ result.push(record);
+ }
+ return result;
+ },
+
// Every unordered pair with its current status. With a handful of
// civilisations the whole matrix is tiny, and it lets the client show the
// status of every nation without a second lookup.
@@ -392,7 +500,7 @@ export const serializationMethods = {
for (let a = 0; a < this.civilisations.length; a++) {
const row = [];
for (let b = 0; b < this.civilisations.length; b++) {
- row.push(roundTo(this._weightedApproval(this._territoryByCiv.get(a) || [], b), 3));
+ row.push(roundTo(this._weightedOpinions(this._territoryByCiv.get(a) || [], b).approval, 3));
}
result.push(row);
}
diff --git a/shared/game_state/territory.js b/shared/game_state/territory.js
index ff1e674..7ae289f 100644
--- a/shared/game_state/territory.js
+++ b/shared/game_state/territory.js
@@ -3,6 +3,8 @@
import { key, parseKey } from "../hex.js";
import { NEWS_CAPTURE } from "../data/relations.js";
+import { POPULARITY } from "../data/politics.js";
+import { tileImprovementById } from "../data/improvements.js";
import { serializeCells } from "./helpers.js";
export const territoryMethods = {
@@ -60,6 +62,22 @@ export const territoryMethods = {
this._indexTerritory();
},
+ // The tiles a nation's fortifications anchor: a fort's own tile and its
+ // neighbours. Even if an enemy takes the nearby city, these tiles stay.
+ _fortifiedControl(civ) {
+ const anchored = new Set();
+ for (const [k, id] of this.tileImprovements) {
+ const proto = tileImprovementById(id);
+ if (!proto || !proto.permanentControl) continue;
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ anchored.add(k);
+ for (const neighbour of this._neighbours(parseKey(k))) {
+ anchored.add(key(neighbour.x, neighbour.y));
+ }
+ }
+ return anchored;
+ },
+
// Subdivides each nation's territory into the regions of its own cities: a
// multi-source BFS grown from every city, but never crossing into another
// nation's land. Ties between equally close cities are settled by city order,
@@ -140,9 +158,13 @@ export const territoryMethods = {
if (oldCiv === newCiv) return false;
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
+ // Fortifications hold a permanent area of control: their tile and the ring
+ // around it stay with the builder even when the city nearby changes hands.
+ const anchored = this._fortifiedControl(oldCiv);
city.civ = newCiv;
for (const coords of region) {
const k = key(coords.x, coords.y);
+ if (anchored.has(k)) continue;
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
}
// The captured city's own tile always changes hands, ties or not.
@@ -151,6 +173,14 @@ export const territoryMethods = {
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
+ // Aircraft caught on the captured airfield change allegiance with it.
+ this._captureCityAircraft(city, newCiv);
+ // The city's buildings now serve a different nation, so its modifier
+ // totals changed hands.
+ this._touchModifiers();
+ // Taking a city heartens the conqueror's people and demoralises the losers.
+ this._shiftOwnPopularity(newCiv, POPULARITY.cityCapture);
+ this._shiftOwnPopularity(oldCiv, -POPULARITY.cityLoss);
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
diff --git a/shared/game_state/visibility.js b/shared/game_state/visibility.js
index bc83edd..20f2e17 100644
--- a/shared/game_state/visibility.js
+++ b/shared/game_state/visibility.js
@@ -1,8 +1,9 @@
// Fog of war and the grid walkers. Explored cells stay known; visible cells
// are the ones currently watched by territory and units.
-import { key } from "../hex.js";
+import { key, parseKey } from "../hex.js";
import { UNIT_VISION, TERRITORY_VISION } from "./constants.js";
+import { tileImprovementById } from "../data/improvements.js";
import { serializeSet } from "./helpers.js";
export const visibilityMethods = {
@@ -30,7 +31,16 @@ export const visibilityMethods = {
for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION);
}
for (const unit of this.units) {
- this._reveal(unit.civ, unit.coords, UNIT_VISION);
+ const proto = this.unitProto(unit);
+ this._reveal(unit.civ, unit.coords, (proto && proto.vision) || UNIT_VISION);
+ }
+ // Fortifications and radar towers watch their own neighbourhood too.
+ for (const [k, id] of this.tileImprovements) {
+ const proto = tileImprovementById(id);
+ if (!proto || !proto.vision) continue;
+ const owner = this.tileImprovementOwner.get(k);
+ if (owner === undefined || owner < 0) continue;
+ this._reveal(owner, parseKey(k), Math.max(UNIT_VISION, proto.vision));
}
},
diff --git a/shared/game_state/warfare.js b/shared/game_state/warfare.js
new file mode 100644
index 0000000..fbadc19
--- /dev/null
+++ b/shared/game_state/warfare.js
@@ -0,0 +1,362 @@
+// War-time tile works: trenches dug by infantry, the military improvements a
+// nation raises on its land (fortifications, radar towers, coastal cannons),
+// and the damage, capture and upkeep that come with them.
+
+import { key, parseKey } from "../hex.js";
+import { ECONOMY } from "../data/economy.js";
+import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
+import { TRENCH } from "../data/combat.js";
+import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
+
+export const warfareMethods = {
+ // ----------------------------------------------------------- trenches --
+
+ // Infantry dig in on a land tile their nation controls. A trench shelters
+ // the garrison from melee and artillery at the cost of local GDP.
+ requestDigTrench(unitIds) {
+ const units = this._unitsForOrder(unitIds);
+ if (units.length === 0) return false;
+ const coords = units[0].coords;
+ if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
+ return false;
+ }
+ const civ = units[0].civ;
+ if (!units.every((unit) => unit.civ === civ)) return false;
+ // Only infantry can dig, and only into their own land.
+ if (!units.every((unit) => {
+ const proto = this.unitProto(unit);
+ return proto && proto.infantry;
+ })) return false;
+ if (this.civAt(coords) !== civ) return false;
+ const tile = this.tiles[key(coords.x, coords.y)];
+ if (!tile || tile.terrainClass !== "Land") return false;
+ const k = key(coords.x, coords.y);
+ if (this.trenches.has(k)) return false;
+ this.trenches.add(k);
+ this._gdpPerCapitaCache.delete(k);
+ this._emitChanged();
+ return true;
+ },
+
+ // ------------------------------------------------- tile improvements --
+
+ // Queues a military tile improvement on a land tile the nation controls. The
+ // work takes the same GDP-scaled time as any other construction and completes
+ // on the hourly tile-works tick.
+ requestBuildTileImprovement(civ, coords, id) {
+ if (!this._validCiv(civ) || !coords) return false;
+ if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
+ const proto = tileImprovementById(id);
+ if (!proto) return false;
+ const k = key(coords.x, coords.y);
+ const tile = this.tiles[k];
+ if (!tile || tile.terrainClass !== "Land") return false;
+ if (this.civAt(coords) !== civ) return false;
+ if (this.tileImprovements.has(k)) return false;
+ if (proto.coastal && !this._isCoastalTile(coords)) return false;
+ const gdp = this.getPlayerGdp(civ);
+ if (ECONOMY.productionCapacity(gdp) <= 0) return false;
+ if (!this._spendBudget(civ, proto.buildCost)) return false;
+ const work = {
+ coords: { x: coords.x, y: coords.y },
+ id,
+ elapsedHours: 0,
+ totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
+ };
+ const queue = this.tileWorks.get(civ);
+ if (queue) queue.push(work);
+ else this.tileWorks.set(civ, [work]);
+ this._emitChanged();
+ return true;
+ },
+
+ // Tears down a military improvement (or a trench) on a controlled tile.
+ requestRemoveTileImprovement(civ, coords) {
+ if (!this._validCiv(civ) || !coords) return false;
+ const k = key(coords.x, coords.y);
+ if (this.civAt(coords) !== civ) return false;
+ let removed = false;
+ if (this.trenches.delete(k)) removed = true;
+ if (this.tileImprovements.delete(k)) {
+ this.tileImprovementOwner.delete(k);
+ this.tileImprovementHp.delete(k);
+ removed = true;
+ }
+ if (!removed) return false;
+ this._tileImprovementVersion += 1;
+ this._gdpPerCapitaCache.delete(k);
+ this._emitChanged();
+ return true;
+ },
+
+ // Advances every nation's queued tile works by an hour, completing the head
+ // of each queue and carrying the overflow into the next.
+ _tickTileWorks() {
+ for (const [civ, queue] of this.tileWorks) {
+ let hoursLeft = 1;
+ while (hoursLeft > 0 && queue.length > 0) {
+ const work = queue[0];
+ const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
+ if (hoursLeft >= remaining) {
+ hoursLeft -= remaining;
+ this._completeTileWork(civ, work);
+ queue.shift();
+ } else {
+ work.elapsedHours += hoursLeft;
+ hoursLeft = 0;
+ }
+ }
+ if (queue.length === 0) this.tileWorks.delete(civ);
+ }
+ },
+
+ _completeTileWork(civ, work) {
+ const k = key(work.coords.x, work.coords.y);
+ // The tile may have been taken or already improved while the work ran.
+ if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
+ const proto = tileImprovementById(work.id);
+ if (!proto) return;
+ this.tileImprovements.set(k, work.id);
+ this.tileImprovementOwner.set(k, civ);
+ this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
+ this._tileImprovementVersion += 1;
+ this._gdpPerCapitaCache.delete(k);
+ this._visibilityDirty = true;
+ this._emitChanged();
+ },
+
+ // Whether a land tile touches the sea, so a coastal cannon may be raised.
+ _isCoastalTile(coords) {
+ for (const neighbour of this._neighbours(coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (tile && tile.terrainClass === "Sea") return true;
+ }
+ return false;
+ },
+
+ _tileImprovementAt(coords) {
+ const id = this.tileImprovements.get(key(coords.x, coords.y));
+ return id ? tileImprovementById(id) : null;
+ },
+
+ // The extra defense a tile's fortifications and trenches give a defender,
+ // against a melee or artillery attacker.
+ _tileFortificationMultiplier(coords, attackerProto) {
+ const k = key(coords.x, coords.y);
+ const artillery = this._isArtilleryAttack(attackerProto);
+ let multiplier = 1;
+ const id = this.tileImprovements.get(k);
+ if (id) {
+ const proto = tileImprovementById(id);
+ if (proto && proto.defense) {
+ multiplier *= 1 + (artillery ? proto.defense.artillery : proto.defense.melee);
+ }
+ }
+ if (this.trenches.has(k)) {
+ multiplier *= artillery
+ ? TRENCH.artilleryDefenseMultiplier
+ : TRENCH.meleeDefenseMultiplier;
+ }
+ return multiplier;
+ },
+
+ // Whether an attacker's blow is artillery (or aircraft) fire rather than a
+ // melee charge, so trench and fortification bonuses can tell them apart.
+ _isArtilleryAttack(proto) {
+ if (!proto) return false;
+ if (proto.artillery || proto.air) return true;
+ return (proto.range || 1) > 1;
+ },
+
+ // Damage a tile improvement from ranged fire. Fortifications and coastal
+ // cannons are knocked down when their hit points run out; a radar tower is
+ // captured by infantry rather than destroyed.
+ _damageTileImprovement(coords, amount) {
+ const k = key(coords.x, coords.y);
+ const id = this.tileImprovements.get(k);
+ if (!id) return false;
+ const proto = tileImprovementById(id);
+ if (!proto || !(proto.takesRangedDamage || proto.takesDamage || proto.takesCombatDamage)) {
+ return false;
+ }
+ const hp = (this.tileImprovementHp.get(k) || 0) - amount;
+ if (hp <= 0) {
+ this.tileImprovements.delete(k);
+ this.tileImprovementOwner.delete(k);
+ this.tileImprovementHp.delete(k);
+ this._tileImprovementVersion += 1;
+ this._emitChanged();
+ return true;
+ }
+ this.tileImprovementHp.set(k, hp);
+ return true;
+ },
+
+ // An enemy walking onto a capturable improvement takes it for their nation.
+ _captureTileImprovement(coords, civ) {
+ const k = key(coords.x, coords.y);
+ const id = this.tileImprovements.get(k);
+ if (!id) return false;
+ const proto = tileImprovementById(id);
+ if (!proto || !proto.capturable) return false;
+ const owner = this.tileImprovementOwner.get(k);
+ if (owner === civ) return false;
+ this.tileImprovementOwner.set(k, civ);
+ this._tileImprovementVersion += 1;
+ this._emitChanged();
+ return true;
+ },
+
+ // The hourly maintenance of every military improvement a nation holds.
+ getTileImprovementUpkeep(civ) {
+ let total = 0;
+ for (const [k, id] of this.tileImprovements) {
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = tileImprovementById(id);
+ if (proto) total += proto.maintenance;
+ }
+ return total;
+ },
+
+ _tileImprovementUpkeepSources(civ) {
+ const byType = new Map();
+ for (const [k, id] of this.tileImprovements) {
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = tileImprovementById(id);
+ if (!proto) continue;
+ const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
+ entry.amount += proto.maintenance;
+ entry.count += 1;
+ byType.set(id, entry);
+ }
+ return Array.from(byType.values()).filter((entry) => entry.amount > 0);
+ },
+
+ // ------------------------------------------------------ transports --
+
+ // Infantry board a friendly launch. They must stand on the shore beside it;
+ // the boarding takes EMBARK_HOURS in-game hours to complete.
+ requestEmbark(infantryIds, launchId) {
+ const launch = this.findUnit(Number(launchId));
+ if (!launch) return false;
+ const proto = this.unitProto(launch);
+ if (!proto || !(proto.transportCapacity > 0)) return false;
+ if (launch.embarking) return false;
+ const cargo = launch.cargo || [];
+ const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
+ const unitProto = this.unitProto(unit);
+ return unitProto && unitProto.infantry && unit.civ === launch.civ;
+ });
+ if (infantry.length === 0) return false;
+ if (cargo.length + infantry.length > proto.transportCapacity) return false;
+ // The troops must be on the shore next to the launch.
+ if (!infantry.every((unit) =>
+ this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
+ return false;
+ }
+ launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
+ this._emitChanged();
+ return true;
+ },
+
+ // Finishes any boarding: at zero hours the infantry leave the map and become
+ // the launch's cargo, and are carried until put ashore.
+ _tickEmbark() {
+ for (const unit of this.units) {
+ if (!unit.embarking) continue;
+ unit.embarking.hoursLeft -= 1;
+ if (unit.embarking.hoursLeft > 0) continue;
+ const proto = this.unitProto(unit);
+ const capacity = proto ? proto.transportCapacity || 0 : 0;
+ if (!unit.cargo) unit.cargo = [];
+ for (const id of unit.embarking.ids) {
+ if (unit.cargo.length >= capacity) break;
+ const infantry = this.findUnit(id);
+ if (!infantry || infantry.civ !== unit.civ) continue;
+ unit.cargo.push({ proto: infantry.proto, hp: infantry.hp });
+ this._destroyUnit(infantry);
+ this._visibilityDirty = true;
+ }
+ unit.embarking = null;
+ this._emitChanged();
+ }
+ },
+
+ // Puts a whole stack of launches' cargo ashore on an adjacent coastal tile.
+ requestDisembark(launchIds, coords) {
+ if (!coords) return false;
+ const launches = this._unitsForOrder(launchIds).filter((unit) => {
+ const proto = this.unitProto(unit);
+ return proto && proto.transportCapacity > 0;
+ });
+ if (launches.length === 0) return false;
+ const civ = launches[0].civ;
+ if (!launches.every((unit) => unit.civ === civ)) return false;
+ const k = key(coords.x, coords.y);
+ const tile = this.tiles[k];
+ if (!tile || tile.terrainClass !== "Land") return false;
+ if (!launches.every((unit) => this.topology.tileDistance(unit.coords, coords) === 1)) {
+ return false;
+ }
+ let landed = false;
+ for (const launch of launches) {
+ const cargo = launch.cargo || [];
+ for (const passenger of cargo) {
+ const proto = this.protoUnits[passenger.proto];
+ if (!proto) continue;
+ const unit = this._spawnUnit(coords, civ, proto);
+ if (passenger.hp !== undefined) unit.hp = Math.min(proto.maxHp, passenger.hp);
+ landed = true;
+ }
+ launch.cargo = [];
+ }
+ if (landed) {
+ this._visibilityDirty = true;
+ this._emitChanged();
+ }
+ return landed;
+ },
+
+ // -------------------------------------------------- coastal cannons --
+
+ // Coastal cannons fire at hostile warships within range every hour.
+ _tickCoastalCannons() {
+ const damaged = new Map();
+ for (const [k, id] of this.tileImprovements) {
+ const proto = tileImprovementById(id);
+ if (!proto || !proto.automatic) continue;
+ const owner = this.tileImprovementOwner.get(k);
+ const coords = parseKey(k);
+ for (const target of this._unitsWithin(coords, proto.range || 1)) {
+ const targetProto = this.unitProto(target);
+ if (!targetProto || !targetProto.naval) continue;
+ if (target.civ === owner) continue;
+ const amount = COASTAL_CANNON.damage;
+ damaged.set(target, (damaged.get(target) || 0) + amount);
+ }
+ }
+ if (damaged.size === 0) return;
+ const destroyed = [];
+ for (const [unit, amount] of damaged) {
+ unit.hp -= amount;
+ if (unit.hp <= 0) {
+ unit.hp = 0;
+ destroyed.push(unit);
+ }
+ }
+ this._recordMilitaryCasualties(destroyed);
+ for (const unit of destroyed) this._destroyUnit(unit);
+ if (destroyed.length > 0) this._visibilityDirty = true;
+ this._emitChanged();
+ },
+
+ // Every unit of another nation within `radius` tiles of `coords`.
+ _unitsWithin(coords, radius) {
+ const result = [];
+ const origin = { x: coords.x, y: coords.y };
+ for (const unit of this.units) {
+ if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit);
+ }
+ return result;
+ },
+};
diff --git a/shared/game_state/world.js b/shared/game_state/world.js
index b0ff2c2..9d0a24e 100644
--- a/shared/game_state/world.js
+++ b/shared/game_state/world.js
@@ -4,6 +4,7 @@
import { ECONOMY } from "../data/economy.js";
import { ROADS } from "../data/roads.js";
+import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js";
import { MapGenerator } from "../map_generator.js";
@@ -173,20 +174,23 @@ export const worldMethods = {
return candidate;
},
- // Lays the pre-generated road network. Cities are founded with a road, so
- // they are free nodes while the network is drawn; the chosen links are the
- // greedy spanner over every possible city-to-city route.
+ // Lays the pre-generated road network. Cities are founded on a railway (they
+ // improve themselves), so they are free nodes while the network is drawn; the
+ // chosen links are the greedy spanner over every possible city-to-city route.
_generateRoads(roadKeys = null) {
- for (const city of this.cities) {
- if (!city.improvements.includes(ROADS.improvementName)) {
- city.improvements.push(ROADS.improvementName);
- }
- }
+ const railwayName = transportImprovement("railway").name;
// A cached world hands back the (read-only) road tiles; copy them so no
// state can alter the cache.
this.roads = roadKeys
? new Set(roadKeys)
: buildRoadNetwork(this.cities, this.topology, this.tiles);
+ // A city always stands on a railway, so no player has to improve it.
+ for (const city of this.cities) {
+ if (!city.improvements.includes(railwayName)) city.improvements.push(railwayName);
+ const k = key(city.coords.x, city.coords.y);
+ this.roads.delete(k);
+ this.railways.add(k);
+ }
},
_spawnUnit(center, civIndex, proto, homeCityId = null) {
@@ -214,6 +218,9 @@ export const worldMethods = {
strikeTarget: null,
strikeReadyHour: 0,
missionFlown: false,
+ // A transport's cargo and any boarding under way.
+ cargo: [],
+ embarking: null,
};
if (proto.air) {
unit.homeCityId = homeCityId;
diff --git a/shared/hex_pathfinder.js b/shared/hex_pathfinder.js
index c09d557..f59dbfe 100644
--- a/shared/hex_pathfinder.js
+++ b/shared/hex_pathfinder.js
@@ -2,14 +2,16 @@
// the graph with callbacks so the algorithm stays pure and testable:
//
// neighbours(coords) -> [{x,y}, ...]
-// canEnter(coords, isGoal) -> bool
+// canEnter(coords, isGoal, from) -> bool
// stepCost(from, to) -> number
// heuristic(from, to) -> number
//
// canEnter is told whether the tile is the destination, so a caller can allow
// entering a tile it would otherwise route around (a hostile unit or city) only
-// as the very goal. findPath returns the cells from start to goal inclusive, or
-// [] when the goal cannot be entered or is unreachable.
+// as the very goal, and which tile the step came from, so a caller can forbid
+// crossing from one denied tile straight into another. findPath returns the
+// cells from start to goal inclusive, or [] when the goal cannot be entered or
+// is unreachable.
import { key } from "./hex.js";
@@ -97,7 +99,7 @@ export class HexPathfinder {
for (const neighbour of neighbours(current)) {
const neighbourKey = key(neighbour.x, neighbour.y);
if (closed.has(neighbourKey)) continue;
- if (!canEnter(neighbour, neighbourKey === goalKey)) continue;
+ if (!canEnter(neighbour, neighbourKey === goalKey, current)) continue;
const tentative = (gScore.has(currentKey) ? gScore.get(currentKey) : Infinity) +
stepCost(current, neighbour);
if (tentative < (gScore.has(neighbourKey) ? gScore.get(neighbourKey) : Infinity)) {
diff --git a/shared/rules.js b/shared/rules.js
index 6c33a6c..d9f36fa 100644
--- a/shared/rules.js
+++ b/shared/rules.js
@@ -1,7 +1,25 @@
// 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, ECONOMY } from "./data.js";
+import { effectValue, CITY_DEFENSE_BONUS, ECONOMY, POPULARITY, TRENCH } from "./data.js";
+
+// The popularity scale shared by the server (which simulates it) and the
+// browser (which shows it). Opinions live on the 0..1 scale; popularity is the
+// same figure in integer points centred on neutral 0, and what the player sees
+// is a logistic of those points.
+export function popularityPoints(opinion) {
+ return Math.round((opinion - 0.5) / POPULARITY.opinionPerPoint);
+}
+
+export function popularityDisplay(points) {
+ return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points));
+}
+
+// The hourly pace at which a population at `points` returns to neutral: one
+// point an hour, so the estimate is simply the distance left.
+export function hoursToNeutral(points) {
+ return Math.abs(points);
+}
// 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.
@@ -100,6 +118,7 @@ export function gdpPerCapitaFactors(tile, {
nextToCity = false,
pillagePenalty = 1,
battleDeficit = 0,
+ trench = false,
regionalMultiplier = 1,
regionalLabel = null,
nationalMultiplier = 1,
@@ -117,6 +136,9 @@ export function gdpPerCapitaFactors(tile, {
if (pillagePenalty !== 1) {
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
}
+ if (trench) {
+ factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
+ }
// City improvements only shape the region their city controls, so a tile's
// GDP per capita carries its region's multiplier plus the nation-wide one
// from government and research. Both are factors on the per-capita figure,
diff --git a/tests/air_test.js b/tests/air_test.js
index c019c91..6f2f162 100644
--- a/tests/air_test.js
+++ b/tests/air_test.js
@@ -500,3 +500,63 @@ export class AirTest extends TestCase {
this.assertTrue(state._isStrikeUnit(jet), "an aircraft attacks from the air");
}
}
+
+// A sea tile, unoccupied by hulls, from which a battleship can shell the shore.
+function freeSeaNearCoast(state) {
+ for (const k in state.tiles) {
+ const coords = parseKey(k);
+ if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(coords) || state.cityAt(coords)) {
+ continue;
+ }
+ for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
+ if (!state._isLand(neighbour)) continue;
+ if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
+ return { sea: coords, land: { x: neighbour.x, y: neighbour.y } };
+ }
+ }
+ return null;
+}
+
+function freeSeaNear(state, coords, maxDistance) {
+ for (const k in state.tiles) {
+ const cell = parseKey(k);
+ if (state.tiles[k].terrainClass !== "Sea" || state.unitAt(cell) || state.cityAt(cell)) continue;
+ if (state.topology.tileDistance(cell, coords) <= maxDistance) return cell;
+ }
+ return null;
+}
+
+export class CoastalBombardTest extends TestCase {
+ test_a_battleship_bombards_a_coastal_unit() {
+ const state = smallState();
+ const spot = freeSeaNearCoast(state);
+ this.assertNotNull(spot, "the map has an open coast");
+ const battleship = state._spawnUnit(spot.sea, 0, protoUnit(state, "battleship"));
+ const defender = state._spawnUnit(spot.land, 1, protoUnit(state, "modern_infantry"));
+ this.assertTrue(
+ state.requestBombard([battleship.id], defender.coords),
+ "the warship opens fire on the shore"
+ );
+ this.assertEqual(defender.hp, defender.maxHp, "the first salvo waits for the hour");
+ state.advanceHour();
+ this.assertLess(defender.hp, defender.maxHp, "the coastal unit is hit");
+ this.assertEqual(battleship.coords.x, spot.sea.x, "the warship holds station");
+ this.assertEqual(battleship.coords.y, spot.sea.y);
+ }
+
+ test_a_battleship_shells_a_defenceless_coastal_city() {
+ const state = smallState();
+ const city = state.cities.find(
+ (c) => c.civ === 1 && state.isCoastalCity(c) && state.unitsAt(c.coords).length === 0
+ );
+ this.assertNotNull(city, "the enemy has a defenceless coastal city");
+ const sea = freeSeaNear(state, city.coords, 2);
+ this.assertNotNull(sea, "there is open water within the guns' reach of the city");
+ const battleship = state._spawnUnit(sea, 0, protoUnit(state, "battleship"));
+ const k = `${city.coords.x},${city.coords.y}`;
+ const before = state.tilePopulation.get(k) || 0;
+ this.assertTrue(state.requestBombard([battleship.id], city.coords), "the city is a target");
+ state.advanceHour();
+ this.assertLess(state.tilePopulation.get(k) || 0, before, "the bombardment killed civilians");
+ }
+}
diff --git a/tests/combat_test.js b/tests/combat_test.js
index 867c299..5f569fa 100644
--- a/tests/combat_test.js
+++ b/tests/combat_test.js
@@ -393,17 +393,29 @@ export class CombatTest extends TestCase {
this.assertLess(infantry.hp, infantryHp, "the infantry is hit back");
}
- test_melee_charges_an_artillery_and_hits_it() {
+ test_infantry_captures_an_unaccompanied_artillery_it_charges() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
- this.assertEqual(state._combatTarget(infantry), artillery);
- const artilleryHp = artillery.hp;
- state._resolveCombatRound();
- this.assertLess(artillery.hp, artilleryHp, "an adjacent artillery has almost no protection");
+ // With no friendly infantry beside it, the battery is taken instead of
+ // fought, so there is no battle to resolve.
+ this.assertEqual(artillery.civ, 1, "the artillery is captured");
+ this.assertNull(state._combatTarget(infantry), "no melee follows the capture");
+ }
+
+ test_artillery_sheltered_by_infantry_still_fights() {
+ const state = smallState();
+ const pair = findEmptyLandPair(state);
+ const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
+ state._spawnUnit(pair.a, 0, state.protoUnits[0]);
+ const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
+ this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
+ state.advanceMovement(1000);
+ this.assertEqual(artillery.civ, 0, "the artillery stays in the fight");
+ this.assertNotNull(state._combatTarget(infantry), "there is a battle to resolve");
}
test_a_battle_removes_a_share_of_population_and_gdp_each_day() {
diff --git a/tests/data_test.js b/tests/data_test.js
index 522b5d5..6748b64 100644
--- a/tests/data_test.js
+++ b/tests/data_test.js
@@ -16,6 +16,7 @@ import {
STATUS_BATTLE,
PILLAGE,
pillageSteps,
+ POLICIES,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -104,6 +105,24 @@ export class DataTest extends TestCase {
}
}
+ test_policy_effects_are_popularity_points_not_percentages() {
+ for (const policy of POLICIES) {
+ this.assertFalse(
+ policy.targetEffect.includes("%"),
+ `${policy.id} target effect is stated in popularity points`
+ );
+ this.assertFalse(
+ policy.politicalCost.includes("%"),
+ `${policy.id} political cost is stated in popularity points`
+ );
+ }
+ }
+
+ test_market_and_bank_are_cheap_starter_buildings() {
+ this.assertEqual(buildingById("market").baseCost, 500_000);
+ this.assertEqual(buildingById("bank").baseCost, 1_000_000);
+ }
+
test_technologies_form_a_tree() {
const ids = new Set(TECHNOLOGIES.map((t) => t.id));
for (const tech of TECHNOLOGIES) {
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index f0cd38b..1d70f2a 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -1,7 +1,8 @@
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 { BUILDINGS, CIVILISATIONS, TECHNOLOGIES } from "../shared/data.js";
+import { parseKey } from "../shared/hex.js";
import { smallState, adjacentLand } from "./framework/helpers.js";
function stubNetwork() {
@@ -85,7 +86,7 @@ export class GameScreenTest extends TestCase {
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");
+ this.assertSize(env.$("#city-train-list button"), 3, "infantry, artillery and radar can be trained");
env.$("#city-train-list button").first().click();
this.assertEqual(network.orders[0], { type: "train", city: capital.id, proto: 0 });
@@ -115,7 +116,7 @@ export class GameScreenTest extends TestCase {
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);
+ this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length);
screen.leave();
} finally {
teardownDom(env);
@@ -155,6 +156,45 @@ export class GameScreenTest extends TestCase {
}
}
+ async test_top_panel_arrows_compare_to_last_month() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ screen.onState(state.snapshot(0));
+ // January 2000 carries no baseline, so no change markers.
+ this.assertSize(env.$("#stat-pop .stat-arrow"), 0, "no arrow without a past month");
+
+ const snapshot = state.snapshot(0);
+ const stats = snapshot.viewerStats;
+ snapshot.monthly = [{
+ population: stats.population / 2,
+ gdp: stats.gdp / 2,
+ gdpPerCapita: stats.gdpPerCapita / 2,
+ popularity: stats.popularity,
+ approval: stats.approval,
+ budget: stats.budget,
+ }];
+ screen.onState(snapshot);
+ this.assertEqual(
+ env.$("#stat-pop .stat-arrow").text(),
+ "▲▲",
+ "a doubling population shows two green up arrows"
+ );
+ this.assertTrue(env.$("#stat-pop .stat-arrow").hasClass("up"));
+ this.assertEqual(
+ env.$("#stat-approval .stat-arrow").text(),
+ "=",
+ "an unchanged popularity shows an equals sign"
+ );
+ this.assertTrue(env.$("#stat-approval .stat-arrow").hasClass("equal"));
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_buildings_and_nation_modals_refresh_on_snapshot() {
const env = await setupDom();
try {
@@ -479,7 +519,7 @@ export class GameScreenTest extends TestCase {
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");
+ this.assertEqual(before, 3, "only infantry, artillery and radar are trainable at first");
// The airport is completed server-side and a fresh snapshot arrives while
// the panel is still open.
@@ -510,7 +550,7 @@ export class GameScreenTest extends TestCase {
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");
+ this.assertSize(env.$("#city-train-list .free-button"), 3, "a free button per trainable unit");
env.$("#city-train-list .free-button").first().click();
this.assertEqual(network.orders[0], {
type: "train",
@@ -692,10 +732,10 @@ export class GameScreenTest extends TestCase {
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);
+ state.requestFocusTechnology(0, method);
+ state._addResearch(0, state.getTechnologyCost(0, method));
screen.onState(state.snapshot(0));
this.assertTrue(screen._researched.has(method), "researched technologies are mirrored");
@@ -780,6 +820,57 @@ export class GameScreenTest extends TestCase {
}
}
+ async test_unit_panel_shows_transport_cargo_and_boarding() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ const launchProto = state.protoUnits.find((u) => u.id === "launch");
+ const infantryProto = state.protoUnits.find((u) => u.id === "modern_infantry");
+ const seaKey = Object.keys(state.tiles).find(
+ (k) => state.tiles[k].terrainClass === "Sea"
+ );
+ this.assertNotNull(seaKey, "the map has a sea tile");
+ const spot = parseKey(seaKey);
+ const launch = state._spawnUnit(spot, 0, launchProto);
+ launch.cargo = [{ proto: state.protoUnits.indexOf(infantryProto), hp: infantryProto.maxHp }];
+ launch.embarking = { ids: [99], hoursLeft: 6 };
+ const snapshot = state.snapshot(0);
+ screen.onState(snapshot);
+ screen._showUnit(snapshot.units.find((u) => u.id === launch.id));
+
+ const cargo = env.$("#unit-cargo");
+ this.assertFalse(cargo.hasClass("hidden"));
+ this.assertTrue(cargo.text().includes("Cargo 1/1"), "the berths are shown");
+ this.assertTrue(cargo.text().includes(infantryProto.name), "the passenger is named");
+ this.assertTrue(cargo.text().includes("boarding 1"), "a boarding party is shown");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_unit_panel_hides_cargo_for_a_plain_unit() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ const spot = state.landCells.find(
+ (c) => state.tiles[`${c.x},${c.y}`].terrainClass === "Land" && !state.cityAt(c)
+ );
+ const unit = state._spawnUnit(spot, 0, state.protoUnits[0]);
+ const snapshot = state.snapshot(0);
+ screen.onState(snapshot);
+ screen._showUnit(snapshot.units.find((u) => u.id === unit.id));
+ this.assertTrue(env.$("#unit-cargo").hasClass("hidden"));
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_move_into_peaceful_territory_prompts_for_war() {
const env = await setupDom();
try {
@@ -874,6 +965,30 @@ export class GameScreenTest extends TestCase {
}
}
+ async test_focusing_a_technology_sends_the_order() {
+ const env = await setupDom();
+ try {
+ const network = stubNetwork();
+ const screen = new GameScreen(network, makeConfig());
+ screen.enter();
+ screen.onState(smallState().snapshot(0));
+ env.$("#civ-flag").click();
+ env.$("#modal-nation .tab[data-tab=technology]").click();
+ const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
+ env.$("#technology-list .row-card")
+ .eq(method)
+ .find(".actions button")
+ .click();
+ this.assertEqual(network.orders[0], {
+ type: "focus_technology",
+ technology: method,
+ });
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_news_feed_shows_declarations() {
const env = await setupDom();
try {
@@ -1278,7 +1393,7 @@ export class GameScreenTest extends TestCase {
}
}
- async test_build_button_opens_the_improvement_picker_and_sends_an_order() {
+ async test_improvement_buttons_send_build_and_remove_orders() {
const env = await setupDom();
try {
const network = stubNetwork();
@@ -1294,13 +1409,20 @@ export class GameScreenTest extends TestCase {
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
- this.assertFalse(env.$("#tile-build").hasClass("hidden"), "the build button is offered");
- env.$("#tile-build").click();
- const options = env.$("#tile-build-popover .build-option");
+ const options = env.$("#tile-improvement-actions .transport-btn");
this.assertEqual(options.length, 2, "road and railway are listed");
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
- // Clicking the road choice (through the delegated handler) sends the order.
+ // Each is a square icon with its name in the tooltip and cost beneath.
+ this.assertTrue(
+ options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
+ "the road button shows its icon"
+ );
+ this.assertEqual(options.eq(0).find(".improvement-cost").text(), "€50M", "cost under the icon");
+ this.assertTrue(
+ options.eq(0).attr("title").startsWith("Road"),
+ "the name is the tooltip"
+ );
options.eq(0).click();
const order = network.orders[network.orders.length - 1];
this.assertEqual(order.type, "build_improvement");
@@ -1308,15 +1430,167 @@ export class GameScreenTest extends TestCase {
this.assertApprox(order.coords[1], coords.y);
this.assertEqual(order.improvement, "road");
- // A tile that already carries the improvement offers only the upgrade.
+ // A tile that already carries the improvement greys it out, keeps the
+ // upgrade and offers a remove control.
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
screen._showTile({ x: road[0], y: road[1] });
- env.$("#tile-build").click();
- const again = env.$("#tile-build-popover .build-option");
+ const again = env.$("#tile-improvement-actions .improvement-btn").not(".remove");
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
- again.eq(1).click();
- this.assertEqual(network.orders[network.orders.length - 1].improvement, "railway");
+ const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
+ this.assertSize($remove, 1, "a remove control is offered");
+ $remove.click();
+ this.assertEqual(network.orders[network.orders.length - 1], {
+ type: "remove_improvement",
+ coords: [road[0], road[1]],
+ });
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_tile_panel_shows_improvement_build_progress() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ const coords = state.landCells.find((cell) => {
+ const k = `${cell.x},${cell.y}`;
+ return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.unitAt(cell);
+ });
+ this.assertNotNull(coords, "the player owns a bare land tile");
+ this.assertTrue(
+ state.requestBuildTileImprovement(0, coords, "radar_tower"),
+ "the work is queued"
+ );
+ state.advanceHour();
+ screen.onState(state.snapshot(0));
+ screen._showTile(coords);
+ const label = env.$("#tile-works .city-production-label").text();
+ this.assertTrue(label.startsWith("Building Radar tower"), `the work is named: ${label}`);
+ this.assertGreater(env.$("#tile-works .progress").length, 0, "a progress bar is drawn");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_double_clicking_a_city_opens_its_panel() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
+ screen.onState(state.snapshot(0));
+ const marker = env.$(`#layer-entities .city[data-city-id='${capital.id}'] .marker`).get(0);
+ this.assertNotNull(marker, "the capital is drawn");
+ // One click selects the city and shows its quick panel.
+ env.$(marker).trigger("click");
+ this.assertFalse(env.$("#city-info").hasClass("hidden"), "a click opens the quick panel");
+ this.assertTrue(env.$("#modal-city").hasClass("hidden"), "a click does not open the modal");
+ // The second click opens the full city panel.
+ marker.dispatchEvent(
+ new env.window.MouseEvent("dblclick", { bubbles: true, cancelable: true })
+ );
+ this.assertFalse(env.$("#modal-city").hasClass("hidden"), "the city panel opens");
+ this.assertTrue(
+ env.$("#city-title").text().startsWith(capital.name),
+ "the panel is for the clicked city"
+ );
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_clicking_a_unit_also_opens_its_tile_panel() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ const unit = state.units.find((u) => u.civ === 0);
+ screen.onState(state.snapshot(0));
+ screen.map.onUnitSelected(unit.id);
+ this.assertFalse(env.$("#unit-panel").hasClass("hidden"), "the unit panel opens");
+ this.assertFalse(env.$("#tile-panel").hasClass("hidden"), "the tile panel opens too");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_stack_panel_checklist_narrows_the_selection() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ const unit = state.units.find((u) => u.civ === 0);
+ const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
+ screen.onState(state.snapshot(0));
+ screen._selectUnits([unit.id, second.id]);
+ this.assertSize(env.$("#unit-stack-list input"), 2, "each stacked unit is listed");
+ env.$("#unit-stack-list input").eq(0).prop("checked", false).trigger("change");
+ this.assertEqual(screen.selectedUnitIds.length, 1, "unticking narrows the selection");
+ this.assertEqual(screen.selectedUnitIds[0], second.id);
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_city_tile_panel_shows_current_production() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
+ state.requestTrain(capital.id, 0);
+ state.training.get(capital.id)[0].elapsedHours = state.training.get(capital.id)[0].totalHours / 2;
+ screen.onState(state.snapshot(0));
+ screen.map.onCitySelected(capital.id);
+ this.assertTrue(
+ env.$("#city-production").text().includes("Training"),
+ "the city panel shows what is in production"
+ );
+ this.assertTrue(
+ parseFloat(env.$("#city-production .city-production-bar > div").css("width")) > 0,
+ "the quick view has a progress bar"
+ );
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_build_tooltip_forecasts_the_income_gain() {
+ 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();
+ env.$("#modal-city .tab[data-tab='buildings']").click();
+ const bank = state.protoBuildings.findIndex((b) => b.id === "bank");
+ const $upgrade = env.$("#city-buildings-list .row-card")
+ .eq(bank)
+ .find("button")
+ .first();
+ this.assertTrue(
+ ($upgrade.attr("title") || "").includes("Expected budget income"),
+ "the upgrade button forecasts the budget gain"
+ );
screen.leave();
} finally {
teardownDom(env);
diff --git a/tests/game_server_test.js b/tests/game_server_test.js
index a818d9d..d944404 100644
--- a/tests/game_server_test.js
+++ b/tests/game_server_test.js
@@ -272,11 +272,11 @@ export class GameServerOrdersTest extends TestCase {
this.assertEqual(server.state.getGovernment(0), 1);
}
- test_technology_order() {
+ test_focus_technology_order() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
- server.state.research.set(0, 1.0e6);
- this.assertTrue(server.handleOrder(1, { type: "technology", technology: 0 }));
+ this.assertTrue(server.handleOrder(1, { type: "focus_technology", technology: 0 }));
+ this.assertEqual(server.state.getFocusedTechnology(0), 0);
}
test_move_order_with_bad_coords_is_rejected_cleanly() {
diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js
index 6535e66..9bf61fb 100644
--- a/tests/game_state_buildings_test.js
+++ b/tests/game_state_buildings_test.js
@@ -125,13 +125,16 @@ export class GameStateBuildingsTest extends TestCase {
building.effects.push({ stat: EFFECT_RESEARCH, baseValue: 2.0, exponent: 1.0 });
const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
- this.assertEqual(state.getResearch(0), 0);
+ // Research only accumulates into a focused technology, so focus one.
+ const method = state.technologies.findIndex((t) => t.id === "scientific_method");
+ this.assertTrue(state.requestFocusTechnology(0, method));
+ this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
- const first = state.getResearch(0);
+ const first = state.getTechnologyProgress(0);
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
this.assertApprox(first - governmentRate, 2.0);
state.advanceHour();
- this.assertApprox(state.getResearch(0) - first - governmentRate, 2.0);
+ this.assertApprox(state.getTechnologyProgress(0) - first - governmentRate, 2.0);
}
test_viewer_stats_split_income_and_upkeep() {
@@ -191,10 +194,10 @@ export class GameStateBuildingsTest extends TestCase {
const state = smallState();
const snapshot = state.snapshot(0);
this.assertSize(snapshot.buildings, state.protoBuildings.length);
- this.assertHas(snapshot, "research");
+ this.assertHas(snapshot, "focusTechnology");
this.assertHas(snapshot, "culture");
for (const city of snapshot.cities) this.assertHas(city, "buildings");
- this.assertHas(snapshot.viewerStats, "research");
+ this.assertHas(snapshot.viewerStats, "researchRate");
this.assertHas(snapshot.viewerStats, "culture");
}
diff --git a/tests/game_state_government_test.js b/tests/game_state_government_test.js
index 7d27776..a711bdf 100644
--- a/tests/game_state_government_test.js
+++ b/tests/game_state_government_test.js
@@ -50,9 +50,11 @@ export class GameStateGovernmentTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const technocracy = state.governments.findIndex((g) => g.id === "technocracy");
this.assertTrue(state.requestSetGovernment(0, technocracy));
- this.assertEqual(state.getResearch(0), 0);
+ const method = state.technologies.findIndex((t) => t.id === "scientific_method");
+ this.assertTrue(state.requestFocusTechnology(0, method));
+ this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
- this.assertApprox(state.getResearch(0), 3.0);
+ this.assertApprox(state.getTechnologyProgress(0), 3.0);
}
test_snapshot_exposes_government() {
diff --git a/tests/game_state_technology_test.js b/tests/game_state_technology_test.js
index 2def1cf..ee6329d 100644
--- a/tests/game_state_technology_test.js
+++ b/tests/game_state_technology_test.js
@@ -1,14 +1,22 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
-import { TECHNOLOGIES } from "../shared/data.js";
-
-const BIG_RESEARCH = 100000.0;
+import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
+import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
export class GameStateTechnologyTest extends TestCase {
index(state, id) {
return state.technologies.findIndex((t) => t.id === id);
}
+ // Focuses `id` and pours exactly its cost of points into it, the way a nation
+ // would over time; returns the index.
+ complete(state, id, civ = 0) {
+ const index = this.index(state, id);
+ this.assertTrue(state.requestFocusTechnology(civ, index), `focusing ${id}`);
+ state._addResearch(civ, state.getTechnologyCost(civ, index));
+ return index;
+ }
+
test_catalogue_links_prerequisites() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
@@ -24,79 +32,160 @@ export class GameStateTechnologyTest extends TestCase {
this.assertEmpty(state.getResearchedIndices(0));
}
- test_request_research_spends_points_and_marks_it_known() {
+ test_research_points_fill_the_focused_technology() {
const state = smallState();
const index = this.index(state, "scientific_method");
- state.research.set(0, BIG_RESEARCH);
- const cost = state.technologies[index].cost;
- this.assertTrue(state.requestResearchTechnology(0, index));
- this.assertTrue(state.isResearched(0, index));
- this.assertApprox(state.getResearch(0), BIG_RESEARCH - cost);
- this.assertFalse(state.requestResearchTechnology(0, index), "already known");
+ this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted");
+ const cost = state.getTechnologyCost(0, index);
+ // Points flow in as they are created; just short of the cost leaves it in
+ // progress rather than in a bank.
+ state._addResearch(0, cost - 1);
+ this.assertFalse(state.isResearched(0, index), "still researching");
+ this.assertApprox(state.getTechnologyProgress(0), cost - 1, 1e-6);
+ state._addResearch(0, 1);
+ this.assertTrue(state.isResearched(0, index), "the technology is discovered");
+ this.assertEqual(state.getFocusedTechnology(0), -1, "the focus clears");
}
- test_request_research_rejects_unknown_and_unaffordable() {
+ test_switching_focus_keeps_committed_progress() {
const state = smallState();
- state.research.set(0, BIG_RESEARCH);
- this.assertFalse(state.requestResearchTechnology(0, 999), "unknown technology");
- this.assertFalse(state.requestResearchTechnology(999, 0), "unknown civilisation");
- state.research.set(0, 0);
- this.assertFalse(state.requestResearchTechnology(0, 0), "cannot afford it");
+ this.complete(state, "scientific_method");
+ const weapons = this.index(state, "infantry_weapons");
+ const armour = this.index(state, "body_armour");
+ this.assertTrue(state.requestFocusTechnology(0, weapons));
+ state._addResearch(0, 7);
+ this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9);
+ // Switch focus: the weapons points stay committed.
+ this.assertTrue(state.requestFocusTechnology(0, armour));
+ this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9, "weapons kept its work");
+ this.assertApprox(state.getTechnologyProgress(0, armour), 0, 1e-9);
+ state._addResearch(0, 5);
+ this.assertApprox(state.getTechnologyProgress(0, armour), 5, 1e-9);
+ // Switching back resumes from the seven points already committed.
+ this.assertTrue(state.requestFocusTechnology(0, weapons));
+ this.assertApprox(state.getTechnologyProgress(0, weapons), 7, 1e-9);
+ this.assertEqual(state.getTechnologyProgress(0), 7, "the focus reports its own progress");
+ this.assertApprox(state.snapshot(0).techProgress[0][weapons], 7, 1e-9, "progress is shipped");
}
- test_request_research_rejects_when_prerequisites_are_missing() {
+ test_nothing_is_researched_without_a_focus() {
+ const state = smallState();
+ const index = this.index(state, "scientific_method");
+ // Points created with nothing focused are simply not stored anywhere.
+ state._addResearch(0, state.getTechnologyCost(0, index) * 10);
+ this.assertFalse(state.isResearched(0, index));
+ this.assertApprox(state.getTechnologyProgress(0), 0, 1e-9);
+ }
+
+ test_focus_rejects_unknown_technology_and_civilisation() {
+ const state = smallState();
+ this.assertFalse(state.requestFocusTechnology(0, 999), "unknown technology");
+ this.assertFalse(state.requestFocusTechnology(999, 0), "unknown civilisation");
+ }
+
+ test_focus_rejects_when_prerequisites_are_missing() {
const state = smallState();
- state.research.set(0, BIG_RESEARCH);
const space = this.index(state, "space_program");
this.assertFalse(state.canResearchTechnology(0, space));
- this.assertFalse(state.requestResearchTechnology(0, space));
+ this.assertFalse(state.requestFocusTechnology(0, space));
}
test_prerequisites_unlock_the_tree() {
const state = smallState();
- state.research.set(0, BIG_RESEARCH);
- const method = this.index(state, "scientific_method");
const automation = this.index(state, "industrial_automation");
- const quantum = this.index(state, "quantum_computing");
- const space = this.index(state, "space_program");
- this.assertFalse(state.requestResearchTechnology(0, automation), "locked before its prerequisite");
- this.assertTrue(state.requestResearchTechnology(0, method));
- this.assertTrue(state.requestResearchTechnology(0, automation));
- this.assertTrue(state.requestResearchTechnology(0, quantum));
- this.assertTrue(state.requestResearchTechnology(0, space));
+ this.assertFalse(state.requestFocusTechnology(0, automation), "locked before its prerequisite");
+ this.complete(state, "scientific_method");
+ this.assertFalse(state.isResearched(0, automation), "a prerequisite is not auto-granted");
+ this.complete(state, "industrial_automation");
+ this.assertTrue(state.isResearched(0, automation));
+ this.complete(state, "quantum_computing");
+ this.assertTrue(state.requestFocusTechnology(0, this.index(state, "space_program")));
}
test_technology_effects_are_folded_into_gdp() {
const state = smallState();
- state.research.set(0, BIG_RESEARCH);
const before = state.getPlayerGdp(0);
- const method = this.index(state, "scientific_method");
- this.assertTrue(state.requestResearchTechnology(0, method));
+ this.complete(state, "scientific_method");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
}
test_researched_technology_adds_its_research_rate() {
const withTech = smallState();
const without = smallState();
- withTech.research.set(0, BIG_RESEARCH);
- const method = this.index(withTech, "scientific_method");
- this.assertTrue(withTech.requestResearchTechnology(0, method));
- const base = withTech.getResearch(0);
- withTech.advanceHour();
- without.advanceHour();
- const delta = withTech.getResearch(0) - base - without.getResearch(0);
- this.assertApprox(delta, 1.0);
+ this.complete(withTech, "scientific_method");
+ this.assertApprox(
+ withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate,
+ 1.0,
+ 1e-9
+ );
}
test_snapshot_exposes_technologies_and_progress() {
const state = smallState();
- state.research.set(0, BIG_RESEARCH);
- const index = this.index(state, "scientific_method");
- this.assertTrue(state.requestResearchTechnology(0, index));
+ const index = this.complete(state, "scientific_method");
const snapshot = state.snapshot(0);
this.assertSize(snapshot.technologies, state.technologies.length);
this.assertSize(snapshot.researched, state.civilisations.length);
this.assertTrue(snapshot.researched[0].includes(index));
this.assertEmpty(snapshot.researched[1]);
}
+
+ test_repeatable_research_costs_two_percent_more_each_level() {
+ const state = smallState();
+ state.researched.get(0).add(this.index(state, "scientific_method"));
+ const index = this.index(state, "infantry_weapons");
+ const base = state.getTechnologyCost(0, index);
+ this.assertTrue(state.requestFocusTechnology(0, index), "first level");
+ state._addResearch(0, base);
+ this.assertEqual(state.getTechnologyRepeatCount(0, index), 1);
+ this.assertApprox(
+ state.getTechnologyCost(0, index),
+ base * 1.02,
+ 1e-6,
+ "the next level costs 2% more"
+ );
+ this.assertTrue(state.requestFocusTechnology(0, index), "second level");
+ state._addResearch(0, state.getTechnologyCost(0, index));
+ this.assertEqual(state.getTechnologyRepeatCount(0, index), 2);
+ this.assertApprox(state.getTechnologyCost(0, index), base * 1.02 * 1.02, 1e-6);
+ }
+
+ test_repeatable_effects_stack_with_each_level() {
+ const state = smallState();
+ state.researched.get(0).add(this.index(state, "scientific_method"));
+ const weapons = this.index(state, "infantry_weapons");
+ const construction = this.index(state, "construction_technology");
+ for (let level = 0; level < 2; level++) {
+ state.requestFocusTechnology(0, weapons);
+ state._addResearch(0, state.getTechnologyCost(0, weapons));
+ }
+ state.requestFocusTechnology(0, construction);
+ state._addResearch(0, state.getTechnologyCost(0, construction));
+ const modifiers = state.getCivModifiers(0);
+ this.assertApprox(modifiers[EFFECT_INFANTRY_ATTACK], 0.02, 1e-9, "two levels stack");
+ this.assertApprox(modifiers[EFFECT_CONSTRUCTION_SPEED], 0.01, 1e-9);
+ this.assertApprox(state.constructionSpeedMultiplier(0), 1 / 1.01, 1e-9);
+ }
+
+ test_repeatable_research_notifies_without_news() {
+ const state = smallState();
+ this.complete(state, "scientific_method");
+ this.complete(state, "body_armour");
+ const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY);
+ const normalNotice = notices.find((entry) => entry.technology === "scientific_method");
+ const repeatNotice = notices.find((entry) => entry.technology === "body_armour");
+ this.assertNotNull(normalNotice);
+ this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event");
+ this.assertNotNull(repeatNotice);
+ this.assertTrue(repeatNotice.repeatable, "a repeat level is only a notice");
+ }
+
+ test_repeatable_technologies_have_a_theme() {
+ for (const proto of TECHNOLOGIES) {
+ this.assertTrue(
+ typeof proto.theme === "string" && proto.theme.length > 0,
+ `${proto.id} is filed under a theme`
+ );
+ }
+ }
}
diff --git a/tests/game_state_test.js b/tests/game_state_test.js
index 7bf4e5d..ad762bd 100644
--- a/tests/game_state_test.js
+++ b/tests/game_state_test.js
@@ -289,7 +289,7 @@ export class GameStateTest extends TestCase {
for (const key of [
"seed", "viewer", "totalHours", "civs", "protos", "buildings", "governments",
"technologies", "cities", "units", "territory", "population", "budgets",
- "research", "culture", "government", "researched", "training", "viewerStats",
+ "culture", "government", "researched", "focusTechnology", "training", "viewerStats",
"explored", "visible",
]) {
this.assertHas(snapshot, key);
@@ -324,7 +324,7 @@ export class GameStateTest extends TestCase {
const second = state.snapshot(1);
for (const key of [
"seed", "totalHours", "civs", "protos", "buildings", "governments", "technologies",
- "cities", "units", "territory", "population", "budgets", "research", "culture",
+ "cities", "units", "territory", "population", "budgets", "culture",
"government", "researched", "training",
]) {
this.assertEqual(first[key], second[key], `key ${key} is shared`);
diff --git a/tests/improvements_test.js b/tests/improvements_test.js
index 22c3814..55c0a44 100644
--- a/tests/improvements_test.js
+++ b/tests/improvements_test.js
@@ -142,15 +142,38 @@ export class ImprovementsTest extends TestCase {
);
}
- test_a_city_starts_with_a_road_the_player_could_upgrade() {
+ test_an_improvement_can_be_removed_for_free() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const coords = bareOwnedLand(state);
+ const k = key(coords.x, coords.y);
+ this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
+ const before = state.getBudget(0);
+ this.assertTrue(state.requestRemoveImprovement(0, coords), "the railway is removed");
+ this.assertFalse(state.railways.has(k), "the tile is bare again");
+ this.assertApprox(state.getBudget(0), before, 1e-9, "removal is free");
+ this.assertFalse(state.requestRemoveImprovement(0, coords), "nothing left to remove");
+ }
+
+ test_only_the_controller_may_remove_an_improvement() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const coords = bareOwnedLand(state);
+ this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
+ this.assertFalse(
+ state.requestRemoveImprovement(1, coords),
+ "another nation cannot tear up the road"
+ );
+ this.assertTrue(state.roads.has(key(coords.x, coords.y)));
+ }
+
+ test_a_city_is_automatically_improved_with_a_railway() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const city = cityOf(state, 0);
const coords = { x: city.coords.x, y: city.coords.y };
const k = key(coords.x, coords.y);
- this.assertTrue(state.roads.has(k), "a city carries a road");
- this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
- this.assertTrue(state.railways.has(k), "the city road becomes a railway");
- this.assertFalse(state.roads.has(k));
+ this.assertTrue(state.railways.has(k), "a city carries a railway");
+ this.assertFalse(state.roads.has(k), "not a road");
}
}
diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js
index d5eb574..f1b855a 100644
--- a/tests/map_modes_test.js
+++ b/tests/map_modes_test.js
@@ -48,6 +48,32 @@ export class MapModesTest extends TestCase {
this.assertTrue(/^#[0-9a-f]{6}$/.test(mid), `a valid colour: ${mid}`);
}
+ async test_the_population_scale_is_logarithmic() {
+ const scale = ECONOMIC_MODES.population.scale;
+ this.assertTrue(typeof scale === "function", "population reads through a log scale");
+ const linear = economicFraction(100000, 1000, 1000000);
+ const logged = economicFraction(100000, 1000, 1000000, scale);
+ this.assertGreater(logged, linear, "the log scale lifts smaller towns off the floor");
+ // A second MapView mode's figures reach the same scale through the legend.
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ map.setMapMode("population");
+ map.setEconomicData("population", new Map(), 1000, 1000000);
+ this.assertEqual(map.economicScale, scale, "the active scale reaches the renderer");
+ const mid = Math.round(Math.expm1((Math.log1p(1000) + Math.log1p(1000000)) / 2));
+ this.assertEqual(
+ env.$("#map-legend-mid").text(),
+ ECONOMIC_MODES.population.format(mid),
+ "the legend's midpoint is the geometric middle"
+ );
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_every_economic_mode_has_a_label_and_formatter() {
for (const id of ["gdp", "population"]) {
const meta = ECONOMIC_MODES[id];
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index 5fcd19c..77171ca 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -98,7 +98,7 @@ export class MapViewTest extends TestCase {
this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored");
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const capitalKey = `${capital.coords.x},${capital.coords.y}`;
- this.assertTrue(map.roads.has(capitalKey), "a city carries a road");
+ this.assertTrue(map.railways.has(capitalKey), "a city carries a railway");
this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks");
} finally {
teardownDom(env);
@@ -489,7 +489,7 @@ export class MapViewTest extends TestCase {
}
}
- async test_draws_a_bombardment_line_and_ring_when_the_battery_is_selected() {
+ async test_draws_a_bombardment_line_and_ring_always_visible() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const artillery = state._spawnUnit(
@@ -506,13 +506,31 @@ export class MapViewTest extends TestCase {
try {
map.setSelectedUnit(artillery.id);
map.animate(0);
- this.assertSize(env.$("#layer-targets .target-line"), 1, "the line is drawn");
+ const path = env.$("#layer-targets .target-line").get(0);
+ const d = path.getAttribute("d");
+ this.assertTrue(d.includes("Q"), "the barrage is drawn as a curve, not a straight line");
+ // The control point is lifted above both ends, so the shell arcs upwards.
+ const startY = Number(d.split(" ")[2]);
+ const controlY = Number(d.split(" ")[5]);
+ this.assertLess(controlY, startY, "the arc rises above the battery");
+ this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
+ this.assertSize(
+ env.$("#layer-targets .target-selected"),
+ 2,
+ "a selected barrage stands out"
+ );
+ // A standing barrage stays visible even when nothing is selected.
map.clearSelection();
map.animate(0);
- this.assertSize(env.$("#layer-targets .target-line"), 0, "the line clears with selection");
- this.assertSize(env.$("#layer-targets .target-ring"), 0, "the ring clears with selection");
+ this.assertSize(env.$("#layer-targets .target-line"), 1, "the line stays drawn");
+ this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn");
+ this.assertSize(
+ env.$("#layer-targets .target-selected"),
+ 0,
+ "nothing is highlighted without a selection"
+ );
} finally {
teardownDom(env);
}
@@ -796,4 +814,76 @@ export class MapViewTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_city_name_carries_a_production_progress_bar() {
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ const city = state.cities.find((c) => c.civ === 0);
+ state.requestTrain(city.id, 0);
+ state.training.get(city.id)[0].elapsedHours = state.training.get(city.id)[0].totalHours / 4;
+ const { env, map } = await this.build(state);
+ try {
+ const bar = map._cityViews.get(city.id).querySelector(".city-progress");
+ this.assertNotNull(bar, "the city has a progress bar");
+ this.assertNotEqual(bar.style.display, "none", "the bar is shown while producing");
+ this.assertTrue(parseFloat(bar.querySelector(".fill").style.width) > 0, "it is filled");
+ this.assertFalse(bar.classList.contains("building"), "a unit queues a blue bar");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_each_building_level_adds_a_square_around_the_city() {
+ const state = smallState();
+ const city = state.cities.find((c) => c.civ === 0);
+ city.buildings[0] = 2;
+ city.buildings[1] = 1;
+ const { env, map } = await this.build(state);
+ try {
+ const squares = map._cityViews.get(city.id).querySelectorAll(".city-building-square");
+ this.assertEqual(squares.length, 3, "one square per building level");
+ // The first square's angle and radius come straight from the seed hash.
+ const angle = map._cityHash(city.id, 0, 0) * Math.PI * 2;
+ const radius = 0.55 + map._cityHash(city.id, 0, 1) * 0.4;
+ this.assertApprox(
+ parseFloat(squares[0].style.left),
+ 50 + Math.cos(angle) * radius * 100,
+ 1e-6,
+ "the scatter is a deterministic function of the map seed"
+ );
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_a_moving_unit_splits_from_its_stack_at_once() {
+ const state = smallState();
+ const unit = state.units.find((u) => u.civ === 0);
+ const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
+ const { env, map } = await this.build(state);
+ try {
+ const tile = { x: unit.coords[0], y: unit.coords[1] };
+ map._unitMotion.get(unit.id).path = [tile, { x: tile.x + 1, y: tile.y }];
+ map._unitMotion.get(unit.id).segmentHours = [1];
+ map._unitMotion.get(unit.id).pathHours = 0.1;
+ map._assignStackOffsets();
+ this.assertEqual(
+ map._unitViews.get(unit.id).style.visibility,
+ "",
+ "the moving unit is drawn on its own"
+ );
+ this.assertEqual(
+ map._unitViews.get(second.id).style.visibility,
+ "",
+ "the unit left behind stays visible"
+ );
+ this.assertEqual(
+ map._unitViews.get(second.id).querySelector(".unit-count").style.display,
+ "none",
+ "the stack badge is gone the moment one member leaves"
+ );
+ } finally {
+ teardownDom(env);
+ }
+ }
}
diff --git a/tests/modals_test.js b/tests/modals_test.js
index 726f16c..b51eac4 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -244,7 +244,7 @@ export class NationModalTest extends TestCase {
modal.onGovernment = (index) => {
government = index;
};
- modal.onTechnology = (index) => {
+ modal.onFocusTechnology = (index) => {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
@@ -265,7 +265,7 @@ export class NationModalTest extends TestCase {
// Researching its prerequisite unlocks it.
modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6);
this.assertTrue(techRows.eq(method).find(".actions button").text().startsWith("Researched"));
- this.assertTrue(techRows.eq(automation).find(".actions button").text().startsWith("Research"));
+ this.assertTrue(techRows.eq(automation).find(".actions button").text().startsWith("Focus"));
techRows.eq(automation).find(".actions button").click();
this.assertEqual(technology, automation);
@@ -280,12 +280,90 @@ export class NationModalTest extends TestCase {
}
}
+ async test_technology_theme_tabs_filter_the_list() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
+ const $themes = env.$("#technology-themes .subtab");
+ this.assertGreater($themes.length, 1, "the technologies are split into themes");
+ const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
+ const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
+ const rows = env.$("#technology-list .row-card");
+ this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme");
+ this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden");
+ $themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click");
+ this.assertEqual(rows.eq(method).css("display"), "none", "science is hidden");
+ this.assertNotEqual(rows.eq(automation).css("display"), "none", "the economy theme shows");
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_a_repeatable_technology_shows_its_level() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
+ focus: -1,
+ progress: 0,
+ repeatCounts: { infantry_weapons: 3 },
+ });
+ const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
+ const name = env.$("#technology-list .row-card").eq(index).find(".name").text();
+ this.assertTrue(name.includes("Level 3"), `the level is shown: ${name}`);
+ const text = env.$("#technology-list .row-card").eq(index).find(".cost").text();
+ this.assertTrue(text.startsWith("Cost"), "the next level is priced");
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_a_focused_technology_shows_a_progress_bar() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, {
+ focus: method,
+ progress: 10,
+ repeatCounts: {},
+ });
+ const card = env.$("#technology-list .row-card").eq(method);
+ this.assertTrue(card.find(".tech-progress-track").length > 0, "the focus has a bar");
+ this.assertEqual(card.find(".tech-progress-text").text(), "10 / 20", "X / Y beside it");
+ this.assertEqual(card.find(".actions button").text(), "Focused", "no cost on the button");
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_military_technology_effects_are_readable_text() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5);
+ const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
+ this.assertEqual(
+ env.$("#technology-list .row-card").eq(infantry).find(".effects").text(),
+ "+1.0% infantry attack"
+ );
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_reopening_rebuilds_rows_instead_of_stacking_them() {
const env = await setupDom();
try {
const modal = new NationModal();
let technology = null;
- modal.onTechnology = (index) => {
+ modal.onFocusTechnology = (index) => {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
diff --git a/tests/monthly_test.js b/tests/monthly_test.js
new file mode 100644
index 0000000..d057475
--- /dev/null
+++ b/tests/monthly_test.js
@@ -0,0 +1,46 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState } from "./framework/helpers.js";
+import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
+
+export class MonthlyTest extends TestCase {
+ test_january_2000_has_no_month_to_compare_against() {
+ const state = smallState();
+ this.assertNull(state.monthlyStats, "the first month starts with no baseline");
+ const snapshot = state.snapshot(0);
+ this.assertSize(snapshot.monthly, 0, "the snapshot ships an empty baseline in January");
+ }
+
+ test_a_rolled_over_month_becomes_the_baseline() {
+ const state = smallState();
+ // January has 31 days; stepping past its last hour rolls the month over.
+ for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
+ this.assertNotNull(state.monthlyStats, "February has January to compare against");
+ this.assertSize(
+ state.monthlyStats,
+ state.civilisations.length,
+ "every nation carries a baseline"
+ );
+ for (const entry of state.monthlyStats) {
+ this.assertTrue(typeof entry.population === "number", "the population is captured");
+ this.assertTrue(typeof entry.gdp === "number", "the GDP is captured");
+ this.assertTrue(typeof entry.gdpPerCapita === "number", "the GDP/capita is captured");
+ this.assertTrue(typeof entry.popularity === "number", "the popularity is captured");
+ this.assertTrue(typeof entry.budget === "number", "the budget is captured");
+ }
+ const snapshot = state.snapshot(0);
+ this.assertSize(snapshot.monthly, state.civilisations.length, "the snapshot ships the baseline");
+ }
+
+ test_the_baseline_is_the_month_that_just_ended() {
+ const state = smallState();
+ const startPopulation = state.snapshot(0).viewerStats.population;
+ for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
+ // The baseline is the reading at the start of January, not the live one.
+ this.assertApprox(
+ state.monthlyStats[0].population,
+ startPopulation,
+ 1e-6,
+ "the baseline is January's opening figure"
+ );
+ }
+}
diff --git a/tests/politics_test.js b/tests/politics_test.js
index de61174..1d2545f 100644
--- a/tests/politics_test.js
+++ b/tests/politics_test.js
@@ -126,7 +126,8 @@ export class PoliticsModelTest extends TestCase {
const near = smallState();
near.budgets.set(0, BIG_BUDGET);
- near._shiftGovOpinion(0, 1, 0.4);
+ // Close enough to the ceiling that the drift nearly cancels the campaign.
+ near._shiftGovOpinion(0, 1, 0.05);
const nearStart = near.getGovOpinion(0, 1);
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
daysPass(near, 10);
@@ -150,7 +151,8 @@ export class PoliticsModelTest extends TestCase {
const near = smallState();
near.budgets.set(0, BIG_BUDGET);
- near._shiftGovOpinion(0, 1, -0.4);
+ // Close enough to the floor that the drift nearly cancels the campaign.
+ near._shiftGovOpinion(0, 1, -0.05);
const nearStart = near.getGovOpinion(0, 1);
near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
daysPass(near, 10);
@@ -483,6 +485,93 @@ export class PoliticsModelTest extends TestCase {
this.assertGreater(moved.count, 0, "the tile still counts that people");
this.assertLess(moved.trend, 0, "the tile lost some of them");
}
+
+ test_opinions_drift_one_point_an_hour_toward_neutral() {
+ const state = smallState();
+ // Ten points (0.05 on the opinion scale) comes home in ten hours.
+ state._shiftGovOpinion(0, 1, 10 * 0.005);
+ for (let i = 0; i < 9; i++) state.tickHour();
+ this.assertGreater(state.getGovOpinion(0, 1), OPINION.baseline, "still above neutral");
+ state.tickHour();
+ this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "ten points, ten hours");
+ // Below neutral drifts up at the same constant speed.
+ state._shiftGovOpinion(0, 1, -3 * 0.005);
+ for (let i = 0; i < 3; i++) state.tickHour();
+ this.assertApprox(state.getGovOpinion(0, 1), OPINION.baseline, 1e-9, "constant speed either way");
+ }
+
+ test_popularity_points_and_display_follow_the_logistic() {
+ const state = smallState();
+ // Neutral reads 50%, +100 points reads 75%, -100 reads 25%.
+ this.assertApprox(state.getCivPopularityDisplay(0), 0.5, 1e-9);
+ for (let eth = 0; eth < state.civilisations.length; eth++) {
+ state._shiftGovOpinion(eth, 0, 0.5);
+ }
+ this.assertApprox(state.getCivPopularity(0), 100, 1, "the own people are fully won over");
+ this.assertApprox(state.getCivPopularityDisplay(0), 0.75, 1e-6);
+ }
+
+ test_a_large_minority_cannot_be_expelled() {
+ const state = smallState();
+ this.assertLess(state.getCivEthnicShare(0, 1), 0.1, "the minority is small at first");
+ this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1), "a small minority can be expelled");
+
+ const big = smallState();
+ for (const coords of big._territoryByCiv.get(0) || []) {
+ big._setTileEthnicFractions(coords, new Map([[0, 0.7], [1, 0.3]]));
+ }
+ this.assertGreater(big.getCivEthnicShare(0, 1), 0.1, "now a tenth of the country");
+ this.assertFalse(big.requestPolicy(0, POLICY_EXPEL, 1), "too many to drive out");
+ }
+
+ test_declaring_war_costs_the_aggressor_popularity() {
+ const state = smallState();
+ state._shiftGovOpinion(0, 0, 0.2);
+ const before = state.getGovOpinion(0, 0);
+ this.assertTrue(state.requestDeclareWar(0, 1));
+ this.assertLess(state.getGovOpinion(0, 0), before, "the aggressor's own people disapprove");
+ }
+
+ test_being_attacked_by_a_loathed_rival_rallies_the_people() {
+ const state = smallState();
+ // Civ 1's people already loathe civ 0 below the 15% threshold.
+ for (let eth = 0; eth < state.civilisations.length; eth++) {
+ state._shiftGovOpinion(eth, 0, -0.4);
+ }
+ const before = state.getGovOpinion(1, 1);
+ this.assertTrue(state.requestDeclareWar(0, 1));
+ this.assertGreater(state.getGovOpinion(1, 1), before, "the victim's people rally");
+ }
+
+ test_losing_and_killing_units_moves_popularity() {
+ const state = smallState();
+ const tile = state.landCells.find(
+ (coords) => !state.unitAt(coords) && !state.cityAt(coords)
+ );
+ this.assertNotNull(tile, "an empty land tile");
+ const attacker = state._spawnUnit(tile, 0, state.protoUnits[0]);
+ const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
+ defender.hp = 1;
+ const attackerBefore = state.getGovOpinion(0, 0);
+ const defenderBefore = state.getGovOpinion(1, 1);
+ state._resolveCombatRound();
+ this.assertFalse(state.units.includes(defender), "the defender was destroyed");
+ this.assertLess(state.getGovOpinion(1, 1), defenderBefore, "losing a unit hurts at home");
+ this.assertGreater(state.getGovOpinion(0, 0), attackerBefore, "the killer's people cheer");
+ }
+
+ test_taking_a_city_moves_popularity() {
+ const state = smallState();
+ const city = state.cities.find((c) => c.civ === 1);
+ this.assertNotNull(city, "civ 1 has a city");
+ // Clear the garrison and walk a unit in.
+ for (const unit of state.unitsAt(city.coords)) state._destroyUnit(unit);
+ const conquerorBefore = state.getGovOpinion(0, 0);
+ const loserBefore = state.getGovOpinion(1, 1);
+ state._captureCity(city, 0);
+ this.assertGreater(state.getGovOpinion(0, 0), conquerorBefore, "the conqueror's people cheer");
+ this.assertLess(state.getGovOpinion(1, 1), loserBefore, "the losers despair");
+ }
}
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
diff --git a/tests/roads_test.js b/tests/roads_test.js
index a9661a3..f9eef20 100644
--- a/tests/roads_test.js
+++ b/tests/roads_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { buildRoadNetwork, greedySpanner, tileRoadCost } from "../shared/roads.js";
-import { ROADS, TERRAIN_TILES } from "../shared/data.js";
+import { ROADS, TERRAIN_TILES, transportImprovement } from "../shared/data.js";
import { key, parseKey } from "../shared/hex.js";
export class RoadsTest extends TestCase {
@@ -19,26 +19,28 @@ export class RoadsTest extends TestCase {
this.assertEqual(tileRoadCost(sea, true), 0, "a city tile is free");
}
- test_cities_start_with_a_road_and_are_connected_by_the_network() {
+ test_cities_are_founded_on_a_railway_and_connected_by_the_network() {
const state = smallState(["france", "britain", "poland"], 12345, { citiesPerCiv: 3 });
+ const railwayName = transportImprovement("railway").name;
const cityKeys = state.cities.map((city) => key(city.coords.x, city.coords.y));
for (const city of state.cities) {
this.assertTrue(
- city.improvements.includes(ROADS.improvementName),
- "a city is founded with a road"
+ city.improvements.includes(railwayName),
+ "a city is automatically improved with a railway"
);
}
- for (const cityKey of cityKeys) this.assertTrue(state.roads.has(cityKey));
- this.assertGreater(state.roads.size, cityKeys.length, "roads also cross the land");
+ for (const cityKey of cityKeys) this.assertTrue(state.railways.has(cityKey));
+ this.assertGreater(state.roads.size, 0, "roads also cross the land");
- // Following roads from the first city must reach every other city.
+ // Following the network from the first city must reach every other city.
+ const passable = (k) => state.roads.has(k) || state.railways.has(k);
const seen = new Set([cityKeys[0]]);
const queue = [parseKey(cityKeys[0])];
while (queue.length > 0) {
const coords = queue.pop();
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
- if (!state.roads.has(nk) || seen.has(nk)) continue;
+ if (!passable(nk) || seen.has(nk)) continue;
seen.add(nk);
queue.push(neighbour);
}
diff --git a/tests/warfare_test.js b/tests/warfare_test.js
new file mode 100644
index 0000000..55a1c75
--- /dev/null
+++ b/tests/warfare_test.js
@@ -0,0 +1,295 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { key } from "../shared/hex.js";
+import { TRENCH } from "../shared/data/combat.js";
+
+const BIG_BUDGET = 1.0e15;
+
+function emptyLandPair(state) {
+ for (const cell of state.landCells) {
+ if (state.unitAt(cell) || state.cityAt(cell)) continue;
+ for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
+ if (!state._isLand(neighbour)) continue;
+ if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
+ return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
+ }
+ }
+ return null;
+}
+
+function ownLand(state, civ = 0) {
+ for (const cell of state.landCells) {
+ if (state.civAt(cell) !== civ) continue;
+ if (state.unitAt(cell) || state.cityAt(cell)) continue;
+ return cell;
+ }
+ return null;
+}
+
+export class TrenchTest extends TestCase {
+ test_infantry_dig_in_on_their_own_land() {
+ const state = smallState();
+ const coords = ownLand(state);
+ this.assertNotNull(coords, "the nation holds land");
+ const infantry = state._spawnUnit(coords, 0, state.protoUnits[0]);
+ this.assertTrue(state.requestDigTrench([infantry.id]));
+ this.assertTrue(state.trenches.has(key(coords.x, coords.y)));
+ this.assertFalse(state.requestDigTrench([infantry.id]), "already dug in");
+ }
+
+ test_only_infantry_can_dig() {
+ const state = smallState();
+ const coords = ownLand(state);
+ const artillery = state._spawnUnit(coords, 0, state.protoUnits[2]);
+ this.assertFalse(state.requestDigTrench([artillery.id]));
+ this.assertFalse(state.trenches.has(key(coords.x, coords.y)));
+ }
+
+ test_trenches_multiply_defense_and_cut_gdp() {
+ const state = smallState();
+ const coords = ownLand(state);
+ const k = key(coords.x, coords.y);
+ const before = state.getTileGdpPerCapita(coords);
+ state.trenches.add(k);
+ state._gdpPerCapitaCache.delete(k);
+ this.assertApprox(
+ state.getTileGdpPerCapita(coords),
+ before * TRENCH.gdpMultiplier,
+ 1e-6,
+ "a trench costs the tile a fifth of its GDP per capita"
+ );
+ // Infantry attackers are blunted partly; artillery far more.
+ const infantryProto = state.protoUnits[0];
+ const artilleryProto = state.protoUnits[2];
+ this.assertApprox(
+ state._tileFortificationMultiplier(coords, infantryProto),
+ TRENCH.meleeDefenseMultiplier,
+ 1e-9
+ );
+ this.assertApprox(
+ state._tileFortificationMultiplier(coords, artilleryProto),
+ TRENCH.artilleryDefenseMultiplier,
+ 1e-9
+ );
+ }
+}
+
+export class CaptureTest extends TestCase {
+ test_infantry_captures_unaccompanied_artillery() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
+ const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
+ this.assertTrue(state.requestMove(infantry.id, pair.a));
+ state.advanceMovement(1000);
+ this.assertEqual(artillery.civ, 1, "the battery changed hands");
+ }
+
+ test_infantry_beside_the_artillery_protects_it() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
+ state._spawnUnit(pair.a, 0, state.protoUnits[0]);
+ const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
+ this.assertTrue(state.requestMove(infantry.id, pair.a));
+ state.advanceMovement(1000);
+ this.assertEqual(artillery.civ, 0, "the artillery is not captured while sheltered");
+ }
+
+ test_walking_onto_a_radar_tower_captures_it() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ const k = key(pair.a.x, pair.a.y);
+ state.tileImprovements.set(k, "radar_tower");
+ state.tileImprovementOwner.set(k, 0);
+ state.tileImprovementHp.set(k, 500);
+ const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
+ this.assertTrue(state.requestMove(infantry.id, pair.a));
+ state.advanceMovement(1000);
+ this.assertEqual(state.tileImprovementOwner.get(k), 1, "the radar tower changed hands");
+ this.assertTrue(state.tileImprovements.has(k), "the tower is captured, not destroyed");
+ }
+
+ test_capturing_a_city_takes_its_aircraft() {
+ const state = smallState();
+ const city = cityOf(state, 1);
+ const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
+ city.buildings[airport] = 1;
+ const jet = state._spawnUnit(
+ city.coords,
+ 1,
+ state.protoUnits.find((u) => u.id === "jet_fighter"),
+ city.id
+ );
+ state._captureCity(city, 0);
+ this.assertEqual(jet.civ, 0, "the aircraft is captured with the city");
+ this.assertEqual(jet.homeCityId, city.id);
+ }
+}
+
+export class TileImprovementTest extends TestCase {
+ test_a_radar_tower_takes_time_to_build() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const coords = ownLand(state);
+ const k = key(coords.x, coords.y);
+ this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
+ this.assertFalse(state.tileImprovements.has(k), "not raised instantly");
+ for (let i = 0; i < 100000 && !state.tileImprovements.has(k); i++) state.advanceHour();
+ this.assertEqual(state.tileImprovements.get(k), "radar_tower", "the work completes");
+ this.assertEqual(state.tileImprovementOwner.get(k), 0);
+ }
+
+ test_a_coastal_cannon_needs_the_sea() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const inland = ownLand(state);
+ this.assertFalse(
+ state.requestBuildTileImprovement(0, inland, "coastal_cannon"),
+ "an inland tile cannot host a coastal cannon"
+ );
+ }
+
+ test_improvements_cost_an_hourly_maintenance() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const coords = ownLand(state);
+ const k = key(coords.x, coords.y);
+ const before = state.getTileImprovementUpkeep(0);
+ state.tileImprovements.set(k, "fortifications");
+ state.tileImprovementOwner.set(k, 0);
+ state.tileImprovementHp.set(k, 2000);
+ this.assertGreater(state.getTileImprovementUpkeep(0), before, "forts carry upkeep");
+ }
+
+ test_fortifications_hold_permanent_control() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ // A tile right beside the city: without the fort it would fall with it.
+ const coords = state.topology
+ .neighbours(city.coords.x, city.coords.y)
+ .find((n) => state._isLand(n) && state.civAt(n) === 0 && !state.unitAt(n) && !state.cityAt(n));
+ this.assertNotNull(coords, "the city borders a tile of its own land");
+ const k = key(coords.x, coords.y);
+ state.tileImprovements.set(k, "fortifications");
+ state.tileImprovementOwner.set(k, 0);
+ state.tileImprovementHp.set(k, 2000);
+ state._captureCity(city, 1);
+ this.assertEqual(state.civAt(coords), 0, "the fort's area of control never changes hands");
+ }
+
+ test_a_coastal_cannon_fires_on_a_warship() {
+ const state = smallState();
+ const coastal = state.landCells.find((cell) =>
+ state.civAt(cell) === 0 &&
+ state.topology.neighbours(cell.x, cell.y).some(
+ (n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
+ )
+ );
+ this.assertNotNull(coastal, "the nation holds a coastal tile");
+ const sea = state.topology.neighbours(coastal.x, coastal.y).find(
+ (n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea"
+ );
+ const k = key(coastal.x, coastal.y);
+ state.tileImprovements.set(k, "coastal_cannon");
+ state.tileImprovementOwner.set(k, 0);
+ state.tileImprovementHp.set(k, 800);
+ const warship = state._spawnUnit(
+ sea,
+ 1,
+ state.protoUnits.find((u) => u.id === "battleship")
+ );
+ const hp = warship.hp;
+ state._tickCoastalCannons();
+ this.assertLess(warship.hp, hp, "the cannon hits the warship");
+ this.assertTrue(state.tileImprovements.has(k), "the cannon survives (only fires)");
+ }
+}
+
+export class TransportTest extends TestCase {
+ coastalSetup(state) {
+ for (const cell of state.landCells) {
+ if (state.civAt(cell) !== 0) continue;
+ for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
+ if (state.tiles[key(neighbour.x, neighbour.y)].terrainClass !== "Sea") continue;
+ if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
+ return { land: { x: cell.x, y: cell.y }, sea: { x: neighbour.x, y: neighbour.y } };
+ }
+ }
+ return null;
+ }
+
+ test_infantry_board_a_launch_and_are_carried() {
+ const state = smallState();
+ const spot = this.coastalSetup(state);
+ this.assertNotNull(spot, "the nation has a coastal tile");
+ const launchProto = state.protoUnits.find((u) => u.id === "launch");
+ const launch = state._spawnUnit(spot.sea, 0, launchProto);
+ const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
+ this.assertTrue(state.requestEmbark([infantry.id], launch.id), "boarding begins");
+ for (let i = 0; i < 24; i++) state.advanceHour();
+ this.assertNull(state.findUnit(infantry.id), "the infantry left the map");
+ this.assertEqual(launch.cargo.length, 1, "the infantry is cargo");
+ }
+
+ test_a_loaded_launch_disembarks_its_stack() {
+ const state = smallState();
+ const spot = this.coastalSetup(state);
+ const launchProto = state.protoUnits.find((u) => u.id === "launch");
+ const launch = state._spawnUnit(spot.sea, 0, launchProto);
+ const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]);
+ state.requestEmbark([infantry.id], launch.id);
+ for (let i = 0; i < 24; i++) state.advanceHour();
+ this.assertTrue(state.requestDisembark([launch.id], spot.land), "the troops go ashore");
+ this.assertEqual(launch.cargo.length, 0, "the launch is empty");
+ this.assertGreater(state.unitsAt(spot.land).length, 0, "a unit stands on the shore");
+ }
+}
+
+export class ZoneOfControlTest extends TestCase {
+ // Two neighbouring, unclaimed land tiles, so terrain and ownership never
+ // interfere with the zone-of-control check under test.
+ neutralPair(state) {
+ for (const cell of state.landCells) {
+ if (state.civAt(cell) !== 0) continue;
+ for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
+ if (!state._isLand(neighbour) || state.civAt(neighbour) !== 0) continue;
+ if (state.unitAt(cell) || state.unitAt(neighbour)) continue;
+ return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
+ }
+ }
+ return null;
+ }
+
+ test_a_unit_cannot_cross_between_two_denied_tiles() {
+ const state = smallState();
+ const pair = this.neutralPair(state);
+ this.assertNotNull(pair, "the map has two adjacent neutral land tiles");
+ const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
+ // The hostile is only denied by the artificial zone, not by occupancy.
+ const zoc = new Set([key(pair.a.x, pair.a.y), key(pair.b.x, pair.b.y)]);
+ this.assertFalse(
+ state._canUnitEnter(mover, pair.b, false, -1, null, zoc, pair.a),
+ "denied to denied is blocked"
+ );
+ this.assertTrue(
+ state._canUnitEnter(mover, pair.b, false, -1, null, new Set([key(pair.b.x, pair.b.y)]), pair.a),
+ "entering the ring from outside is allowed"
+ );
+ this.assertTrue(
+ state._canUnitEnter(mover, pair.b, true, -1, null, zoc, pair.a),
+ "a deliberate attack on the goal is allowed"
+ );
+ }
+
+ test_a_hostile_unit_denies_its_tile_and_neighbours() {
+ const state = smallState();
+ const pair = emptyLandPair(state);
+ state._spawnUnit(pair.a, 1, state.protoUnits[0]);
+ const zoc = state._zoneOfControl(0);
+ this.assertTrue(zoc.has(key(pair.a.x, pair.a.y)), "the hostile's tile is denied");
+ for (const neighbour of state.topology.neighbours(pair.a.x, pair.a.y)) {
+ this.assertTrue(zoc.has(key(neighbour.x, neighbour.y)), "each neighbour is denied");
+ }
+ }
+}