diff --git a/client/css/style.css b/client/css/style.css
index 3449ec5..6493293 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -426,6 +426,9 @@ input:focus, select:focus { border-color: var(--accent); }
display: flex;
align-items: center;
gap: 14px;
+ /* Lifted above the modal overlay so the nation summary — and the approval
+ figure it now carries — stays bright and readable while a panel is open. */
+ z-index: 60;
}
.flag-button {
@@ -451,6 +454,11 @@ 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; }
+/* 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); }
+.stat-value.approval-positive { color: #6ee787; }
+.stat-value.approval-negative { color: #f27272; }
#fps-label {
position: absolute;
@@ -600,6 +608,15 @@ input:focus, select:focus { border-color: var(--accent); }
}
.modal.hidden { display: none; }
+/* The city and government panels dock below the top-left nation summary
+ instead of covering it, so the approval figure stays in view while they are
+ open. The 104px clears the summary's 12px offset plus its padded height. */
+#modal-city,
+#modal-nation {
+ align-items: flex-start;
+ padding: 104px 16px 16px;
+}
+
.modal-card {
width: 620px;
max-height: 80vh;
@@ -615,11 +632,13 @@ input:focus, select:focus { border-color: var(--accent); }
.modal-card-small .row { padding-bottom: 18px; }
/* The nation panel carries wide tables and long policy/campaign rows, so it
- takes most of the screen. */
+ takes most of the screen. A fixed height keeps it from resizing as tabs with
+ wildly different content are switched. */
#modal-nation .modal-card {
width: 90vw;
max-width: 1600px;
- max-height: 90vh;
+ height: min(80vh, 900px);
+ max-height: calc(100vh - 120px);
}
.modal-head {
display: flex;
@@ -637,12 +656,13 @@ input:focus, select:focus { border-color: var(--accent); }
.tabs {
display: flex;
+ /* Wrap rather than scroll: every tab stays visible with no clipped edge,
+ and the bar keeps a stable height whatever tab is selected. */
+ flex-wrap: wrap;
+ flex: 0 0 auto;
gap: 6px;
- padding: 10px 18px 0;
- /* With enough tabs the row overflows; let it scroll instead of being clipped
- by the card's overflow: hidden. */
- overflow-x: auto;
- scrollbar-width: thin;
+ padding: 10px 18px 8px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tab {
cursor: pointer;
@@ -788,13 +808,16 @@ input:focus, select:focus { border-color: var(--accent); }
.training-queue .queue-row { padding: 8px 12px; }
.training-queue .queue-row .actions .aero { min-width: 110px; }
-/* The city modal keeps a fixed, slightly larger size. The unit buttons sit in
- a fixed-height box that scrolls when there are more units than fit; the queue
- below is the flexible part and scrolls too, so adding units never stretches
- the card or pushes the buttons out of reach. */
+/* The city modal keeps a fixed size whatever tab is open, so switching between
+ train, buildings, people and budget never resizes the card. The unit buttons
+ sit in a fixed-height box that scrolls when there are more units than fit;
+ the queue below is the flexible part and scrolls too, so adding units never
+ stretches the card or pushes the buttons out of reach. */
#modal-city .modal-card {
width: 680px;
+ max-width: calc(100vw - 32px);
height: min(80vh, 740px);
+ max-height: calc(100vh - 120px);
}
#modal-city #city-train-list {
flex: 0 0 auto;
diff --git a/client/index.html b/client/index.html
index 86aedf0..e664720 100644
--- a/client/index.html
+++ b/client/index.html
@@ -128,6 +128,7 @@
GDP€0
GDP/capita€0
Budget€0
+ Approval—
60 FPS
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index 3c132eb..a05168b 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -416,6 +416,26 @@ export const panelMethods = {
$("#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);
+ },
+
+ // The summary's approval 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");
+ if (!$approval.length) return;
+ const pct = Math.round(approval * 100);
+ $approval.text(`${pct}%`);
+ $approval
+ .removeClass("approval-neutral approval-positive approval-negative")
+ .addClass(
+ pct < 40
+ ? "approval-negative"
+ : pct > 60
+ ? "approval-positive"
+ : "approval-neutral"
+ );
},
_updateBottomPanel() {
diff --git a/client/js/modals/city.js b/client/js/modals/city.js
index c0dc736..95580bc 100644
--- a/client/js/modals/city.js
+++ b/client/js/modals/city.js
@@ -11,6 +11,7 @@ import {
} from "../../../shared/rules.js";
import { effectsText, signedMoney } from "./format.js";
import { renderBudget } from "./budget.js";
+import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
export class CityModal {
@@ -57,13 +58,7 @@ export class CityModal {
this.onFreeBuild = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
- this.$modal.find(".tab").on("click", (event) => {
- const tab = $(event.currentTarget).data("tab");
- this.$modal.find(".tab").removeClass("active");
- $(event.currentTarget).addClass("active");
- this.$modal.find(".tab-page").removeClass("active");
- $(`#city-tab-${tab}`).addClass("active");
- });
+ bindTabs(this.$modal, "city-tab-");
}
isShowing(cityId) {
diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js
index 2bdd859..764b5ea 100644
--- a/client/js/modals/nation.js
+++ b/client/js/modals/nation.js
@@ -8,6 +8,7 @@ import {
import { POLICIES, PROPAGANDA, policyById } from "../../../shared/data.js";
import { renderBudget } from "./budget.js";
import { effectsText, signedMoney } from "./format.js";
+import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
export class NationModal {
@@ -59,13 +60,7 @@ export class NationModal {
this.onRevokePolicy = () => {};
this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
- this.$modal.find(".tab").on("click", (event) => {
- const tab = $(event.currentTarget).data("tab");
- this.$modal.find(".tab").removeClass("active");
- $(event.currentTarget).addClass("active");
- this.$modal.find(".tab-page").removeClass("active");
- $(`#tab-${tab}`).addClass("active");
- });
+ bindTabs(this.$modal, "tab-");
}
isShowing() {
@@ -628,7 +623,8 @@ export class NationModal {
$card.append(
$("").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."
+ "Your cultural impact decides how strongly the message lands, and the last " +
+ "few percent are the hardest to win."
)
);
$card.append(
diff --git a/client/js/modals/tabs.js b/client/js/modals/tabs.js
new file mode 100644
index 0000000..553c500
--- /dev/null
+++ b/client/js/modals/tabs.js
@@ -0,0 +1,17 @@
+// Shared tab wiring for the tabbed modals. The city and nation panels both
+// render a `.tabs` button row above a set of `.tab-page` sections; clicking a
+// button shows the matching page and hides the rest.
+//
+// `pagePrefix` turns a button's `data-tab` into the id of its page: the city
+// panel names its pages `city-tab-`, the nation panel `tab-`.
+
+export function bindTabs($modal, pagePrefix) {
+ $modal.find(".tab").on("click", (event) => {
+ const $tab = $(event.currentTarget);
+ const tab = $tab.data("tab");
+ $modal.find(".tab").removeClass("active");
+ $tab.addClass("active");
+ $modal.find(".tab-page").removeClass("active");
+ $modal.find(`#${pagePrefix}${tab}`).addClass("active");
+ });
+}
diff --git a/shared/data/politics.js b/shared/data/politics.js
index 292dbf7..3217fce 100644
--- a/shared/data/politics.js
+++ b/shared/data/politics.js
@@ -35,11 +35,19 @@ export const APPROVAL = {
// opinion shift per day at the default spend and balanced cultural impact;
// `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
+// 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.
export const PROPAGANDA = {
defaultHourlyCost: 8_000,
maxHourlyCost: 40_000,
dailyDelta: 0.012,
culturalWeight: 0.6,
+ difficultyExponent: 2,
};
export const CAMPAIGN_ETHNICITY = "ethnicity";
diff --git a/shared/game_state.js b/shared/game_state.js
index 5fdbdd4..2bf366f 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -144,7 +144,13 @@ export class GameState {
this._nextPolicyId = 1;
this._rebellionCooldown = new Map();
this._politicsVersion = 0;
+ // Approval is expensive to weight over every tile and is read far more
+ // often than politics changes, so both the per-city and per-nation results
+ // are memoised against the politics version (and cleared on territory
+ // changes). The full approval matrix the snapshot ships is cached too.
this._approvalCache = new Map();
+ this._civApprovalCache = new Map();
+ this._approvalMatrixCache = null;
this._nextUnitId = 1;
this._nextCityId = 1;
diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js
index 58fdf23..ded5403 100644
--- a/shared/game_state/politics.js
+++ b/shared/game_state/politics.js
@@ -325,9 +325,16 @@ export const politicsMethods = {
return value;
},
- // A nation's government approval among its own people.
+ // A nation's government approval among its own people. Memoised against the
+ // politics version, like getCityApproval, since the snapshot reads it for
+ // every nation on every broadcast.
getCivApproval(civ) {
- return this._weightedApproval(this._territoryByCiv.get(civ) || [], 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;
},
_weightedApproval(coordsList, government) {
@@ -458,7 +465,9 @@ export const politicsMethods = {
// Campaigns run for as long as they are left in place, moving opinion a
// little every day. Spending above the default amplifies the shift and
- // spending below it dampens it.
+ // spending below it dampens it. The shift also shrinks as the target opinion
+ // nears its ceiling or floor, so the last percentage points are the hardest
+ // to win.
_tickCampaigns() {
if (this.campaigns.length === 0) return;
for (const campaign of this.campaigns) {
@@ -469,7 +478,8 @@ export const politicsMethods = {
campaign.direction *
campaign.dailyDelta *
spendFactor *
- this._campaignEffectiveness(campaign);
+ this._campaignEffectiveness(campaign) *
+ campaignHeadroomFactor(campaign.direction, this._campaignOpinion(campaign));
if (campaign.targetKind === CAMPAIGN_GOVERNMENT) {
this._shiftGovOpinion(campaign.observer, campaign.target, delta);
} else {
@@ -478,6 +488,16 @@ export const politicsMethods = {
}
},
+ // The opinion a campaign is trying to move: a government's standing with the
+ // observer for a government campaign, the observer's view of the target
+ // people for an ethnicity campaign.
+ _campaignOpinion(campaign) {
+ if (campaign.targetKind === CAMPAIGN_GOVERNMENT) {
+ return this.getGovOpinion(campaign.observer, campaign.target);
+ }
+ return this.getEthnicOpinion(campaign.observer, campaign.target);
+ },
+
// How strongly a campaign lands: a culturally dominant sponsor out-talks a
// weaker target. Both nations' cultural impacts shape the result.
_campaignEffectiveness(campaign) {
@@ -877,6 +897,18 @@ function clampOpinion(value) {
return value;
}
+// The fraction of a campaign's full daily shift that applies given how far the
+// opinion still is from its ceiling (warming) or floor (cooling). At or below
+// the neutral midpoint the full rate applies; closing on the goal the rate
+// falls away, raised to `difficultyExponent` so the last percent is very hard.
+function campaignHeadroomFactor(direction, opinion) {
+ const room = direction > 0 ? OPINION.max - opinion : opinion - OPINION.min;
+ const neutralRoom = OPINION.max - OPINION.baseline;
+ if (!(neutralRoom > 0)) return 1;
+ const ratio = Math.max(0, Math.min(1, room / neutralRoom));
+ return Math.pow(ratio, PROPAGANDA.difficultyExponent || 1);
+}
+
function normalizeShares(shares) {
let total = 0;
for (const share of shares.values()) total += share;
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index 416a6c0..8e0a491 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -350,7 +350,13 @@ export const serializationMethods = {
// approval[a][b] is the share of civilisation a's people who approve of
// civilisation b's government. The diagonal is each nation's own standing.
+ // The N×N matrix weights every nation's opinion over every tile it holds, so
+ // it is memoised against the politics version: snapshots are broadcast on
+ // every dirty frame but approval only moves when politics does.
_serializeApproval() {
+ if (this._approvalMatrixCache && this._approvalMatrixCache.version === this._politicsVersion) {
+ return this._approvalMatrixCache.value;
+ }
const result = [];
for (let a = 0; a < this.civilisations.length; a++) {
const row = [];
@@ -359,6 +365,7 @@ export const serializationMethods = {
}
result.push(row);
}
+ this._approvalMatrixCache = { version: this._politicsVersion, value: result };
return result;
},
diff --git a/shared/game_state/territory.js b/shared/game_state/territory.js
index c1a27fa..3d6522d 100644
--- a/shared/game_state/territory.js
+++ b/shared/game_state/territory.js
@@ -117,6 +117,9 @@ export const territoryMethods = {
}
this._serializedTerritory = serializeCells(this.territory);
this._gdpPerCapitaCache = new Map();
+ // Which tiles a nation holds changes whose people count toward approval,
+ // so a territory redraw invalidates the memoised approval figures.
+ this._touchPolitics();
},
// Rebuilds the owner index, the regions, the serialised territory and the
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index bfbc4e1..81dbe36 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -99,6 +99,39 @@ export class GameScreenTest extends TestCase {
}
}
+ async test_top_panel_shows_approval_with_colour() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ screen.onState(state.snapshot(0));
+ this.assertEqual(env.$("#stat-approval").text(), "50%");
+ this.assertTrue(env.$("#stat-approval").hasClass("approval-neutral"));
+
+ for (let eth = 0; eth < state.civilisations.length; eth++) {
+ state._shiftGovOpinion(eth, 0, -0.35);
+ }
+ screen.onState(state.snapshot(0));
+ this.assertTrue(
+ env.$("#stat-approval").hasClass("approval-negative"),
+ `disapproval turns red (${env.$("#stat-approval").text()})`
+ );
+
+ for (let eth = 0; eth < state.civilisations.length; eth++) {
+ state._shiftGovOpinion(eth, 0, 0.7);
+ }
+ screen.onState(state.snapshot(0));
+ this.assertTrue(
+ env.$("#stat-approval").hasClass("approval-positive"),
+ `approval turns green (${env.$("#stat-approval").text()})`
+ );
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_buildings_and_nation_modals_refresh_on_snapshot() {
const env = await setupDom();
try {
diff --git a/tests/politics_test.js b/tests/politics_test.js
index cb0eb33..de61174 100644
--- a/tests/politics_test.js
+++ b/tests/politics_test.js
@@ -116,6 +116,64 @@ export class PoliticsModelTest extends TestCase {
this.assertGreater(state.getGovOpinion(0, 1), OPINION.baseline - 0.2, "opinion warmed");
}
+ test_campaign_has_diminishing_returns_near_the_goal() {
+ const mid = smallState();
+ mid.budgets.set(0, BIG_BUDGET);
+ const midStart = mid.getGovOpinion(0, 1);
+ mid.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
+ daysPass(mid, 10);
+ const midGain = mid.getGovOpinion(0, 1) - midStart;
+
+ const near = smallState();
+ near.budgets.set(0, BIG_BUDGET);
+ near._shiftGovOpinion(0, 1, 0.4);
+ const nearStart = near.getGovOpinion(0, 1);
+ near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
+ daysPass(near, 10);
+ const nearGain = near.getGovOpinion(0, 1) - nearStart;
+
+ this.assertGreater(nearGain, 0, "the campaign still moves a nearly convinced people");
+ this.assertLess(
+ nearGain,
+ midGain,
+ "the same spend wins less of the last stretch toward the goal"
+ );
+ }
+
+ test_cooling_campaign_also_slows_near_its_floor() {
+ const mid = smallState();
+ mid.budgets.set(0, BIG_BUDGET);
+ const midStart = mid.getGovOpinion(0, 1);
+ mid.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
+ daysPass(mid, 10);
+ const midDrop = midStart - mid.getGovOpinion(0, 1);
+
+ const near = smallState();
+ near.budgets.set(0, BIG_BUDGET);
+ near._shiftGovOpinion(0, 1, -0.4);
+ const nearStart = near.getGovOpinion(0, 1);
+ near.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, -1);
+ daysPass(near, 10);
+ const nearDrop = nearStart - near.getGovOpinion(0, 1);
+
+ this.assertGreater(nearDrop, 0, "a hated people is still pushed a little further");
+ this.assertLess(nearDrop, midDrop, "cooling likewise slows near the floor");
+ }
+
+ test_campaign_never_pushes_opinion_past_its_bound() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ state._shiftGovOpinion(0, 1, 0.49);
+ state.requestCampaign(0, 0, CAMPAIGN_GOVERNMENT, 1, 1);
+ state.requestSetCampaignSpending(0, state.campaigns[0].id, PROPAGANDA.maxHourlyCost);
+ daysPass(state, 4000);
+ this.assertLess(
+ state.getGovOpinion(0, 1),
+ OPINION.max + 1e-9,
+ "opinion approaches but never passes its ceiling"
+ );
+ }
+
test_campaign_persists_until_cancelled() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
@@ -154,6 +212,21 @@ export class PoliticsModelTest extends TestCase {
this.assertEqual(state.getBudget(0), 0, "nothing was charged up front");
}
+ test_approval_cache_is_invalidated_by_territory_changes() {
+ const state = smallState();
+ // Make the two peoples feel very differently about civ 0's government.
+ state._shiftGovOpinion(0, 0, 0.5);
+ state._shiftGovOpinion(1, 0, -0.5);
+ const before = state.getCivApproval(0);
+ state.getCivApproval(0);
+ const tile = (state._territoryByCiv.get(1) || [])[0];
+ this.assertNotNull(tile, "civ 1 holds at least one tile");
+ state.territory.set(`${tile.x},${tile.y}`, 0);
+ state._reindexTerritory();
+ const after = state.getCivApproval(0);
+ this.assertLess(after, before, "the newly held, hostile people pull approval down");
+ }
+
test_low_approval_slows_building_and_taxes() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);