Compare commits

...
3 Commits
Author SHA1 Message Date
adrien 575f08b64e Enlarged the stack group tick to fill its row 2026-09-28 10:22:00 +02:00
adrien a98cde4e48 Replaced the placeholder submarine icon 2026-09-28 10:16:09 +02:00
adrien da11e1d97d 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.
2026-09-28 10:15:22 +02:00
9 changed files with 199 additions and 55 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

+1 -5
View File
@@ -1,5 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">submarine</text>
</svg>
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M315 144v21.75L292 160l-16 80H164c-49.154 16.385-81.254 27.1-102.578 34.846L52 256l-16-16v44.918C20.047 292.31 20 296.316 20 304c0 7.712.05 11.717 16 19.162V368l16-16 9.352-18.703c21.094 7.734 52.752 18.418 101.072 34.703H372c160-16 160-128 0-128h-16v-64l-23-5.75V144h-18zM148 263h256v18H148v-18z" fill="#9b9b9b" fill-opacity="1" stroke="#4a4a4a" stroke-opacity="1" stroke-width="5"></path></g></svg>

Before

Width:  |  Height:  |  Size: 479 B

After

Width:  |  Height:  |  Size: 556 B

+7 -3
View File
@@ -1200,10 +1200,11 @@ input:focus, select:focus { border-color: var(--accent); }
/* The tri-state group tick: selects or excludes the whole type in one click and
doubles as the group's readout (all / some / none). */
.stack-group-check {
width: 16px; height: 16px; flex: 0 0 auto; padding: 0;
width: 42px; height: 42px;
flex: 0 0 auto; padding: 0; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
border: 1px solid #46566e; border-radius: 4px; background: #101720;
color: #eaf2fc; font-size: 11px; line-height: 1; cursor: pointer;
border: 1px solid #46566e; border-radius: 6px; background: #101720;
color: #eaf2fc; font-size: 26px; line-height: 1; cursor: pointer;
}
.stack-group-check:hover { border-color: #6eafff; }
.stack-group-check.all { background: #3f6fa8; border-color: #6eafff; }
@@ -2263,6 +2264,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);
+18 -18
View File
@@ -1,6 +1,24 @@
// 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": "a98cde4",
"date": "2026-09-28",
"subject": "Replaced the placeholder submarine icon",
"body": ""
},
{
"hash": "da11e1d",
"date": "2026-09-28",
"subject": "Showed the culture a propaganda campaign drains",
"body": "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."
},
{
"hash": "9fc94a8",
"date": "2026-09-28",
"subject": "Rebuilt the technology panel as one lane-routed draggable graph",
"body": ""
},
{
"hash": "3c737e9",
"date": "2026-09-28",
@@ -42,23 +60,5 @@ export const DEVLOG = [
"date": "2026-09-25",
"subject": "Showed the nation's resource pool in the simple economy's top panel",
"body": "The top bar now carries a two-by-two grid of the four storable goods, each a big icon, the grouped pool and an arrow reading production against consumption (purchases excluded); hovering a chip names both daily figures. The quantity column reserves room for 99 999 999 999 so the layout never shifts as stocks grow.\n\nformatResourceAmount paired each prefix with a divisor a thousand times too large, so a million tonnes printed as 1 kt; the steps now move together as kt, Mt, Gt and Tt."
},
{
"hash": "d5305da",
"date": "2026-09-25",
"subject": "Gave the world a simple economy: one pool per nation and a global market",
"body": "The simple economy is now the default and the old simulation is frozen as the hard model, selectable per game. A nation keeps one resource pool available in every region, fed by public production works rather than private per-region stores, and construction and training begin at once, paced by the region's spare power instead of gathering materials over days.\n\nMoney is euro only: a per-inhabitant daily tax funds a treasury spent in a global market, where buy and sell lots trade against the world stock and move its price. The hard economy keeps its per-nation currencies, central banks and exchange rates.\n\nSimple games skip the price-settling warmup and economic migration; the snapshot carries the economy model, the market and the pool figures the client draws. Added the matching test suites and the design briefs under docs/simple_economy."
},
{
"hash": "49613f2",
"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 {