Added the approval summary and fixed the panel layout and propaganda curve

- the nation summary shows own-government approval, white in the neutral 40-60% band, red below and green above
- the city and government panels keep a fixed size per tab, dock below the summary and no longer cover it, and their tab bar wraps instead of being clipped
- propaganda campaigns grow exponentially harder near their opinion ceiling or floor, so the last percent are the hardest to win
- memoised city, nation and matrix approval against the politics version, invalidated by territory redraws, so snapshots stop recomputing it every frame
- extracted the duplicated modal tab wiring into modals/tabs.js
- tests: campaign-difficulty, approval-colour and cache-invalidation cases (513 pass)
This commit is contained in:
2026-09-19 00:36:06 +02:00
parent 256b4d3468
commit 8a7db56b59
13 changed files with 244 additions and 30 deletions
+34 -11
View File
@@ -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;
+1
View File
@@ -128,6 +128,7 @@
<div class="stat"><span class="stat-label">GDP</span><span id="stat-gdp" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">GDP/capita</span><span id="stat-gdppc" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">Budget</span><span id="stat-budget" class="stat-value">€0</span></div>
<div class="stat"><span class="stat-label">Approval</span><span id="stat-approval" class="stat-value approval-neutral">—</span></div>
</div>
</div>
<div id="fps-label">60 FPS</div>
+20
View File
@@ -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() {
+2 -7
View File
@@ -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) {
+4 -8
View File
@@ -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(
$("<div class='desc'></div>").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(
+17
View File
@@ -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-<tab>`, the nation panel `tab-<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");
});
}
+8
View File
@@ -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";
+6
View File
@@ -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;
+36 -4
View File
@@ -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;
+7
View File
@@ -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;
},
+3
View File
@@ -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
+33
View File
@@ -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 {
+73
View File
@@ -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);