diff --git a/client/assets/icon_culture.svg b/client/assets/icon_culture.svg new file mode 100644 index 0000000..853a9b1 --- /dev/null +++ b/client/assets/icon_culture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/css/style.css b/client/css/style.css index d9f87c3..677c527 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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); diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 456f067..88bfe65 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -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": "" } ]; diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index b2b0fe2..e2352db 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -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)) diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 8ced7c7..6410cd5 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -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 = $("
"); + this.$intelligenceCulture = $("
"); this.$intelligenceCampaigns = $("
"); 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($("
").text("Culture")); + const $desc = $("
").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 = $("
"); + $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 = $(""); + $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 = $("").attr({ min: 0, - max: PROPAGANDA.maxHourlyCost, - step: 1000, + max: this._campaignMaxCost(), + step: this._campaignPaidInCulture() ? 1 : 1000, value: campaign.hourlyCost, }); - const $spend = $("").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) => + $("") + .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($("
").text("Launch a propaganda campaign")); $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, 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( - $("
").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 = $("
").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 = $("").append( $("").text("their government"), diff --git a/client/js/ui/card.js b/client/js/ui/card.js index f869b92..a12db11 100644 --- a/client/js/ui/card.js +++ b/client/js/ui/card.js @@ -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 = $(""); + if (proto) { + $span.append($("").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. diff --git a/shared/data/resources.js b/shared/data/resources.js index 8c99534..a758829 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -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; } diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index ecc24b5..8e8a448 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -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 {