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:
+34
-11
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user