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($("