Showed the culture a propaganda campaign drains

The simple economy pays for propaganda out of the nation's culture stock and lapses a campaign it can no longer fund, but the tab quoted money and never named the store. Give culture the clapperboard icon as a non-tradeable resource like research, read the stock and the campaigns' daily drain out on the Propaganda tab, and price every campaign figure in culture when the simple economy runs.
This commit is contained in:
2026-09-28 10:15:22 +02:00
parent 9fc94a8e00
commit da11e1d97d
8 changed files with 182 additions and 35 deletions
+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="m419.682 26.2-8.66 2.452L32.915 135.81 55.27 214.7l386.77-109.608L419.683 26.2zm-12.41 22.224 9.074 32.014-41.086-22.942 32.013-9.072zM350.77 64.438l56.8 31.714-37.084 10.51-56.8-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.718 37.08-10.51zm-61.574 17.45 56.802 31.715-37.084 10.51-56.803-31.715 37.084-10.51zm-61.577 17.45 56.803 31.716-37.084 10.51-56.8-31.717 37.08-10.51zm-61.574 17.45 56.8 31.715-37.083 10.51-56.802-31.715 37.084-10.51zm-45.86 26.227 41.085 22.94-32.01 9.072-9.074-32.012zM55 215v274h402V215H55zm18 18h33.273L73 266.273V233zm58.727 0h38.546l-46 46H85.727l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zm64 0h38.546l-46 46h-38.546l46-46zM439 245.727V279h-33.273L439 245.727zM73 297h366v174H73V297zm248.635 46.57-192.44.703.067 18 192.44-.703-.067-18zM130.7 391.33l-.134 17.998 92.707.703.137-18-92.71-.7zm127.155.7-.2 18 63.913.702.2-17.998-63.913-.703z" fill="#9013fe" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -2263,6 +2263,9 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 4px 6px;
}
.politics-readout { color: var(--muted); font-size: 12px; margin-left: 8px; }
.politics-card .res { display: inline; white-space: nowrap; }
.politics-card .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; }
.culture-readout { margin-left: 0; font-size: 13px; }
.politics-policy {
padding: 8px 0;
border-top: 1px solid rgba(255, 255, 255, 0.07);
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "9fc94a8",
"date": "2026-09-28",
"subject": "Rebuilt the technology panel as one lane-routed draggable graph",
"body": ""
},
{
"hash": "3c737e9",
"date": "2026-09-28",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24",
"subject": "Made training gather materials, fixed the trade graph, and banked money gifts",
"body": "Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw.\n\nThe trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production.\n\nMoney gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with.\n\nPruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references."
},
{
"hash": "afb23df",
"date": "2026-09-24",
"subject": "Cut the migration sweep's repeated lookups and population churn",
"body": ""
}
];
+4
View File
@@ -2744,6 +2744,10 @@ export const panelMethods = {
localCiv,
totalHours: snapshot.totalHours || 0,
campaigns: snapshot.campaigns || [],
// The nation's culture stock, which the simple economy spends on the
// campaigns, and the model that decides which currency they are paid in.
culture: snapshot.culture || [],
economyModel: this.economyModel,
opinions: snapshot.opinions || { ethnic: [], government: [] },
spies: (snapshot.units || [])
.filter((unit) => unit.civ === localCiv && this._isSpy(unit))
+106 -28
View File
@@ -16,6 +16,7 @@ import {
MARKET_LOTS,
resourceById,
PRICE_INDEX_WINDOW_DAYS,
SIMPLE_ECONOMY,
TREATIES,
treatyById,
} from "../../../shared/data.js";
@@ -24,7 +25,7 @@ import { renderMonthlyBudget } from "./budget.js";
import { effectNodes, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { researchNode } from "../ui/card.js";
import { cultureNode, researchNode } from "../ui/card.js";
import { TechnologyTreeView } from "../ui/tech_tree_view.js";
import { resourceNode, valueNode } from "../ui/value.js";
import { lineChart } from "../ui/line_chart.js";
@@ -2097,6 +2098,7 @@ export class NationModal {
this._spyRows = new Map();
this._reportsSignature = null;
this.$intelligenceSpies = $("<div class='politics-card'></div>");
this.$intelligenceCulture = $("<div class='politics-card'></div>");
this.$intelligenceCampaigns = $("<div class='politics-card'></div>");
this.$intelligenceSpyView.append(
this._buildSpiesCard(),
@@ -2104,6 +2106,7 @@ export class NationModal {
this._buildSatellitesCard()
);
this.$intelligencePropagandaView.append(
this._buildCultureCard(),
this.$intelligenceCampaigns,
this._buildCampaignForm(civs)
);
@@ -2114,6 +2117,7 @@ export class NationModal {
this.$intelligenceSub.text(
"Your spies and the propaganda campaigns they serve."
);
this._fillCulture(info, localCiv);
this._fillSpies(info, localCiv);
this._fillReports(info);
this._fillSatellites(info);
@@ -2122,6 +2126,42 @@ export class NationModal {
this._syncCampaignForm();
}
// The culture readout: the simple economy funds propaganda from the culture
// stock, so the tab names the store and what the running campaigns drain from
// it each day. The hard economy pays campaigns from the treasury instead, so
// only the stock (soft power) is worth naming there.
_buildCultureCard() {
const $card = this.$intelligenceCulture;
$card.empty();
$card.append($("<div class='name'></div>").text("Culture"));
const $desc = $("<div class='desc'></div>").text(
this._campaignPaidInCulture()
? "Propaganda is paid for in culture, drained from this store each day; a " +
"campaign lapses when there is too little left to fund it."
: "Your stock of soft power. Campaigns are funded from the treasury."
);
this.$cultureReadout = $("<div class='politics-readout culture-readout'></div>");
$card.append($desc, this.$cultureReadout);
return $card;
}
_fillCulture(info, localCiv) {
if (!this.$cultureReadout || !this.$cultureReadout.length) return;
const culture = (info.culture && info.culture[localCiv]) || 0;
const upkeep = (info.campaigns || [])
.filter((campaign) => campaign.civ === localCiv)
.reduce((sum, campaign) => sum + (campaign.hourlyCost || 0), 0);
const $readout = this.$cultureReadout.empty().append(cultureNode(culture));
if (this._campaignPaidInCulture()) {
$readout
.append(document.createTextNode(" culture · Campaigns consume "))
.append(cultureNode(upkeep * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
} else {
$readout.append(document.createTextNode(" culture"));
}
}
// The spy roster: one row per spy in the field, with a Locate button that
// selects it and brings the map to it. Rows are only rebuilt when the roster
// (or a spy's whereabouts) changes, so an arriving snapshot does not yank a
@@ -2336,19 +2376,51 @@ export class NationModal {
}
for (const campaign of campaigns) {
const row = this._campaignRows.get(campaign.id);
if (row) row.$cost.text(this._campaignCostText(info, campaign));
if (row) row.$cost.empty().append(this._campaignCostNode(info, campaign));
}
}
_campaignCostText(info, campaign) {
// Which currency propaganda is funded from: the simple economy drains the
// culture stock, the hard economy the treasury. The two models also price a
// campaign with their own figures, so the slider bounds come from here too.
_campaignPaidInCulture() {
return this.economyModel === "simple";
}
_campaignDefaultCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.defaultCulturePerHour
: PROPAGANDA.defaultHourlyCost;
}
_campaignMaxCost() {
return this._campaignPaidInCulture()
? SIMPLE_ECONOMY.campaign.maxCulturePerHour
: PROPAGANDA.maxHourlyCost;
}
// A campaign figure in its own currency: culture points with the clapperboard
// icon in the simple economy, quoted euros in the hard one. `amount` is
// already scaled to the period being shown.
_campaignAmountNode(amount) {
if (this._campaignPaidInCulture()) return cultureNode(amount);
return document.createTextNode(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(amount)))}`
);
}
_campaignCostNode(info, campaign) {
const hourly = campaign.hourlyCost || 0;
const activeHours = Math.max(0, (info.totalHours || 0) - (campaign.startedHours || 0));
const spent = hourly * activeHours;
const days = Math.floor(activeHours / HOURS_PER_DAY);
return (
`${currencySymbol()}${groupDigits(Math.round(this._quoted(hourly) * HOURS_PER_DAY))}/day · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${days} day${days === 1 ? "" : "s"}`
);
const $cost = $("<span class='politics-campaign-cost'></span>");
$cost
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day · "))
.append(this._campaignAmountNode(spent))
.append(document.createTextNode(` spent over ${days} day${days === 1 ? "" : "s"}`));
return $cost;
}
_buildCampaignRow(info, localCiv, campaign) {
@@ -2369,30 +2441,32 @@ export class NationModal {
if (campaign.civ === localCiv) {
const $slider = $("<input type='range' class='politics-slider'>").attr({
min: 0,
max: PROPAGANDA.maxHourlyCost,
step: 1000,
max: this._campaignMaxCost(),
step: this._campaignPaidInCulture() ? 1 : 1000,
value: campaign.hourlyCost,
});
const $spend = $("<span class='politics-readout'></span>").text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
$slider.on("input", () =>
$spend.text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(Number($slider.val())) * HOURS_PER_DAY))}/day`
)
);
const spendNode = (hourly) =>
$("<span class='politics-readout'></span>")
.append(this._campaignAmountNode(hourly * HOURS_PER_DAY))
.append(document.createTextNode("/day"));
let $spend = spendNode(campaign.hourlyCost);
$slider.on("input", () => {
const next = spendNode(Number($slider.val()));
$spend.replaceWith(next);
$spend = next;
});
$slider.on("change", () => {
const amount = Number($slider.val());
if (amount <= 0) {
// Reverting to the last known spend keeps the control honest until
// the player confirms the stop.
$slider.val(campaign.hourlyCost);
$spend.text(
`${currencySymbol()}${groupDigits(Math.round(this._quoted(campaign.hourlyCost) * HOURS_PER_DAY))}/day`
);
const next = spendNode(campaign.hourlyCost);
$spend.replaceWith(next);
$spend = next;
this.onConfirm(
"Stop this campaign?",
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
`Cutting the spending to ${this._campaignPaidInCulture() ? "0 culture" : `${currencySymbol()}0`} will stop the campaign.`,
() => this.onCancelCampaign(campaign.id)
);
} else {
@@ -2422,17 +2496,21 @@ export class NationModal {
$card.append($("<div class='name'></div>").text("Launch a propaganda campaign"));
$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, and the last " +
"few popularity points are the hardest to win."
`Spend ${this._campaignPaidInCulture() ? "culture" : "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 popularity points " +
"are the hardest to win."
)
);
$card.append(
$("<div class='desc politics-cost'></div>").text(
`Costs ${currencySymbol()}${groupDigits(Math.round(this._quoted(PROPAGANDA.defaultHourlyCost) * HOURS_PER_DAY))}/day to begin; ` +
"adjust the spending on an active campaign to make it land harder or softer."
const $beginCost = $("<div class='desc politics-cost'></div>").append(
document.createTextNode("Costs "),
this._campaignAmountNode(this._campaignDefaultCost() * HOURS_PER_DAY),
document.createTextNode(
"/day to begin; adjust the spending on an active campaign to make it land " +
"harder or softer."
)
);
$card.append($beginCost);
const $observer = this._civSelect(civs, campaign.observer);
const $kind = $("<select class='politics-select'></select>").append(
$("<option value='government'></option>").text("their government"),
+16
View File
@@ -47,6 +47,22 @@ export function researchNode(amount, { sign = "", decimals = 0 } = {}) {
return $span;
}
// A culture-point figure with its clapperboard icon, standing in for the word
// "culture" the way the erlenmeyer stands in for research. The simple economy
// spends culture on propaganda, so the campaign panels read with the same
// grammar as the research figures.
export function cultureNode(amount, { sign = "", decimals = 0 } = {}) {
const proto = resourceById("culture");
const $span = $("<span class='res culture-points'></span>");
if (proto) {
$span.append($("<img>").attr("src", `assets/${proto.icon}`).attr("alt", proto.name));
}
const value = Number(amount) || 0;
const text = decimals > 0 ? value.toFixed(decimals) : groupDigits(Math.round(value));
$span.append(document.createTextNode(`${sign}${text}`));
return $span;
}
// An inline resource bill for a card: a muted label, then one icon-and-amount
// per material, then an optional suffix. Used for a unit's daily upkeep, which
// is paid in materials rather than money.
+14
View File
@@ -108,8 +108,22 @@ export const RESEARCH = {
colour: "#a142b6",
};
// Culture is the nation's store of soft power: produced by its cultural
// buildings and spent, in the simple economy, on propaganda campaigns. Like
// research it is a special resource -- it has no market, no stockpile tile and
// never moves -- so it is kept out of the tradeable catalogue and shown only
// with its clapperboard icon.
export const CULTURE = {
id: "culture",
name: "Culture",
unit: "",
icon: "icon_culture.svg",
colour: "#9a7bff",
};
export function resourceById(id) {
if (id === RESEARCH.id) return RESEARCH;
if (id === CULTURE.id) return CULTURE;
return RESOURCES[RESOURCE_INDEX[id]] || null;
}
+32 -1
View File
@@ -7,7 +7,7 @@ import { compact, groupDigits } from "../shared/text_format.js";
import { tileImprovementResourceCost } from "../shared/resources.js";
import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
import { parseKey } from "../shared/hex.js";
import { smallState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
import { smallState, simpleState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
function stubNetwork() {
return {
@@ -1598,6 +1598,37 @@ export class GameScreenTest extends TestCase {
}
}
async test_propaganda_tab_shows_the_culture_drain() {
const env = await setupDom();
try {
const state = simpleState();
state.culture.set(0, 12_345);
state.requestCampaign(0, 0, "government", 1, 1);
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.onState(state.snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab='intelligence']").click();
showIntelligenceSubtab(env, "Propaganda");
const $culture = env.$("#intelligence-propaganda-view .culture-readout");
this.assertSize($culture, 1, "the propaganda tab reads out the culture stock");
this.assertTrue($culture.text().includes("12\u00a0345"), "it names the current culture");
this.assertTrue($culture.text().includes("192"), "and what the campaigns consume");
const src = $culture.find("img").first().attr("src");
this.assertTrue(/icon_culture\.svg$/.test(src || ""), "with the culture icon");
const $cost = env.$("#intelligence-propaganda-view .politics-campaign-cost");
this.assertSize($cost, 1, "the running campaign reports its cost");
this.assertGreater($cost.find("img").length, 0, "in culture points");
this.assertFalse($cost.text().includes("\u20ac"), "not in money");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_politics_tab_sends_campaign_and_policy_orders() {
const env = await setupDom();
try {