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 @@ + + + dummy icon + coastal cannon + 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 @@ + + + dummy icon + mobile radar + 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 @@
GDP€0
GDP/capita€0
Budget€0
-
Approval—
+
Popularity—
60 FPS
@@ -154,15 +154,12 @@
-
-
- - -
-
+
+
+ +
+
@@ -319,6 +319,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"); + } + } +}