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);