From 238985b6fe7ee4a370dfea28e591b2809c8a3165 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Tue, 22 Sep 2026 12:54:23 +0200 Subject: [PATCH] Started a famine when a region's food runs out --- client/css/style.css | 1 + client/js/game_screen/panels.js | 14 +++++- client/js/modals/city.js | 15 ++++-- client/js/modals/format.js | 2 + shared/data/relations.js | 2 + shared/data/resources.js | 7 +++ shared/game_state/resources.js | 51 +++++++++++++++++++ shared/game_state/serialization.js | 3 +- tests/famine_test.js | 81 ++++++++++++++++++++++++++++++ tests/modals_test.js | 24 +++++++++ 10 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 tests/famine_test.js diff --git a/client/css/style.css b/client/css/style.css index 5b3be64..440ffb1 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1493,6 +1493,7 @@ input:focus, select:focus { border-color: var(--accent); } border-top: 1px solid rgba(255, 255, 255, 0.05); } .politics-penalty { color: #f0b060; } +.famine-warning { color: #e05a4a; font-weight: 600; } .ethnic-chart { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 8px; } .ethnic-legend { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } .ethnic-legend-row { display: flex; align-items: center; gap: 8px; } diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 489aa80..5933007 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -14,6 +14,7 @@ import { NEWS_WAR, NEWS_TECHNOLOGY, NEWS_JOIN, + NEWS_FAMINE, ECONOMY, EFFECT_PRODUCTION, APPROVAL, @@ -1639,8 +1640,10 @@ export const panelMethods = { debt: cityStats.debt || 0, currencies: stats.currencies || [], approval: cityStats.approval, - // What the region's people went short of over the last day. + // What the region's people went short of over the last day, and whether + // they are starving. shortage: cityStats.shortage || {}, + famine: !!cityStats.famine, // The region's reserves, their value and its daily consumption. resources: cityStats.resources || null, // The trade taxes the region generated over the last day. @@ -1678,7 +1681,8 @@ export const panelMethods = { cityStats.approval, this._taxMultiplier(cityStats.approval), this.civilisations, - cityStats.shortage || {} + cityStats.shortage || {}, + !!cityStats.famine ); this.cityModal.renderResources(cityStats.resources || null); } @@ -1905,6 +1909,12 @@ export const panelMethods = { // else taking control of a nation is the neutral notice (Mouth_08). if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) { audio.play("complete"); + } else if (entry.type === NEWS_FAMINE) { + // A famine concerns everyone, so it raises the alert notice regardless of + // whose region it strikes. + audio.play("notification"); + this.feed.push(describeNews(entry, this.civilisations), { alert: true }); + return; } else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) { audio.play("notification"); } diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 49146e9..b8c4ae6 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -101,7 +101,8 @@ export class CityModal { view.approval, view.taxMultiplier, view.civs || [], - view.shortage || {} + view.shortage || {}, + !!view.famine ); this.renderResources(view.resources); this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0); @@ -110,14 +111,22 @@ export class CityModal { // The region's people: their ethnic makeup and their government approval, // which decides how much tax they pay and how fast they build. `shortage` - // reports the resources they went short of over the last day and by how much. - renderPeople(makeup, approval, taxMultiplier, civs, shortage = {}) { + // reports the resources they went short of over the last day and by how much; + // `famine` says whether they are starving. + renderPeople(makeup, approval, taxMultiplier, civs, shortage = {}, famine = false) { if (!this.$peopleList || !this.$peopleList.length) return; this.$peopleList.empty(); const value = approval === undefined || approval === null ? 0.5 : approval; if (this.$peopleSub && this.$peopleSub.length) { this.$peopleSub.text(`Government approval ${Math.round(value * 100)}%`); } + if (famine) { + this.$peopleList.append( + $("
").text( + "Famine! The region has run out of food and its people are dying." + ) + ); + } if (makeup && makeup.length > 0) { this.$peopleList.append(ethnicityChart(makeup, civs || [])); } else { diff --git a/client/js/modals/format.js b/client/js/modals/format.js index 3296e49..cb69995 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -146,6 +146,8 @@ export function describeNews(entry, civs) { const kind = entry.kind === "export" ? "export tariff" : "import tariff"; return `${adjective(entry.civ)} leaders set the ${kind} on ${resource} to ${rate}`; } + case "famine": + return `${people(entry.civ)} are starving in ${entry.region}: a famine has begun`; default: return entry.type; } diff --git a/shared/data/relations.js b/shared/data/relations.js index 6eac2d7..6979ea8 100644 --- a/shared/data/relations.js +++ b/shared/data/relations.js @@ -19,3 +19,5 @@ export const NEWS_JOIN = "join"; // A government changed an import or export tariff. The involved trading // parties see it in the news feed. export const NEWS_TARIFF = "tariff"; +// A region's food ran out: a famine has begun. Reported to every nation. +export const NEWS_FAMINE = "famine"; diff --git a/shared/data/resources.js b/shared/data/resources.js index f724143..01983c8 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -107,6 +107,13 @@ export const RESOURCE_MARKET = { maxMultiplier: 1.8, }; +// A region whose food store runs dry and cannot feed its people is in famine: +// the whole world is told the day it begins, and the region loses this share of +// its people every day until food comes back. +export const FAMINE = { + dailyMortality: 0.01, +}; + // The physical rules of the resource economy. Every per-person figure and every // energy-per-material ratio below is fixed by the design; the prices above are // what we tune against them. diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 4c6c076..41f81fc 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -16,6 +16,7 @@ import { RESOURCE_RULES, RESOURCE_MARKET_BASE, RESOURCE_TRADE_RADIUS, + FAMINE, tileImprovementById, isResourceTileBuilding, MONEY, @@ -38,6 +39,7 @@ import { buildingBuildCost } from "../rules.js"; import { Random } from "../rng.js"; import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; import { EFFECT_RESEARCH } from "../data/effects.js"; +import { NEWS_FAMINE } from "../data/relations.js"; // A city keeps at least this many days of a material in stock before it will // sell any of it on; the rest is surplus a neighbour may buy. @@ -81,6 +83,8 @@ export const resourceMethods = { } this.resourceLinks = []; this._resourceShortages = new Map(); + // The regions whose food store has run dry and cannot feed their people. + this._famine = new Set(); this._resourceVersion = 0; // What each nation imported and exported over the last simulated day, by // resource, from cross-nation deliveries only. @@ -1274,6 +1278,10 @@ export const resourceMethods = { // stores -- home regions first, then foreign ones. this._consumeCityResources(components, market); + // A region that could not feed its people is in famine: it tells the world, + // and its people start dying. + this._tickFamine(); + // Every nation's buildings, units and works then wear out: the state draws // its stores and buys the rest, and an unpaid bill becomes unrest. for (let civ = 0; civ < this.civilisations.length; civ++) { @@ -1528,6 +1536,49 @@ export const resourceMethods = { } }, + // A region whose food ran out and could not be resupplied is in famine. The + // world is told the day it begins, once per famine rather than daily, and the + // region loses a share of its people every day it lasts. Food coming back ends + // it silently. + _tickFamine() { + for (const city of this.cities) { + const shortage = (this._resourceShortages && this._resourceShortages.get(city.id)) || {}; + const starving = (shortage.food || 0) > 0; + const was = this._famine.has(city.id); + if (starving && !was) { + this._famine.add(city.id); + this._addNews({ type: NEWS_FAMINE, civ: city.civ, region: city.name }); + } else if (!starving && was) { + this._famine.delete(city.id); + } + if (starving) this._applyFamineDeaths(city); + } + }, + + // Famine kills a fixed share of a region's people each day, spread over its + // tiles in proportion to where they live. + _applyFamineDeaths(city) { + let total = 0; + const tiles = []; + for (const coords of this.regionTiles(city)) { + const k = key(coords.x, coords.y); + const population = this.tilePopulation.get(k) || 0; + if (population <= 0) continue; + tiles.push({ k, population }); + total += population; + } + if (total <= 0) return; + for (const tile of tiles) { + this.tilePopulation.set(tile.k, tile.population * (1 - FAMINE.dailyMortality)); + } + this._populationVersion += 1; + }, + + // Whether a region is currently in famine, for its city panel. + isFamine(city) { + return !!(this._famine && this._famine.has(city.id)); + }, + // A city buys a material from the stores it can reach over the transport // network. There is no global market: if no reachable store holds enough, the // rest is simply not bought. Suppliers in the buyer's own nation come first; diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 5cc438e..c276191 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -442,8 +442,9 @@ export const serializationMethods = { cashValue: this.getRegionCashValue(city), debt: this.getRegionDebt(city), // What the region's people went short of over the last day, so the - // People tab can report it. + // People tab can report it, and whether they are starving. shortage: this.getRegionShortages(city), + famine: this.isFamine(city), // The region's reserves, their market value and the private sector's // daily consumption, for the Resources tab. resources: this.getRegionResourceInfo(city), diff --git a/tests/famine_test.js b/tests/famine_test.js new file mode 100644 index 0000000..7962fe2 --- /dev/null +++ b/tests/famine_test.js @@ -0,0 +1,81 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; +import { FAMINE, NEWS_FAMINE } from "../shared/data.js"; + +// A region in famine: its food store has run dry and the day's need went unmet. +function starve(state, city) { + state._resourceShortages = new Map([[city.id, { food: 1 }]]); +} + +export class FamineTest extends TestCase { + test_famine_begins_when_food_runs_out() { + const state = smallState(); + const city = cityOf(state, 0); + this.assertFalse(state.isFamine(city), "a fed region is not in famine"); + starve(state, city); + state._tickFamine(); + this.assertTrue(state.isFamine(city), "running out of food starts a famine"); + } + + test_famine_kills_one_percent_a_day() { + const state = smallState(); + const city = cityOf(state, 0); + const before = state.regionPopulation(city); + this.assertGreater(before, 0, "the region has people"); + starve(state, city); + state._tickFamine(); + const after = state.regionPopulation(city); + this.assertApprox(after, before * (1 - FAMINE.dailyMortality), before * 0.01); + } + + test_famine_is_announced_once_per_occurrence() { + const state = smallState(); + const city = cityOf(state, 0); + starve(state, city); + state._tickFamine(); + state._tickFamine(); + let news = state.news.filter((entry) => entry.type === NEWS_FAMINE); + this.assertEqual(news.length, 1, "a lasting famine is announced only once"); + this.assertEqual(news[0].region, city.name, "the region is named"); + this.assertEqual(news[0].civ, city.civ, "so is its nation"); + + // Food comes back: the famine ends silently... + state._resourceShortages = new Map(); + state._tickFamine(); + this.assertFalse(state.isFamine(city), "food ends the famine"); + // ...and a later famine is fresh news. + starve(state, city); + state._tickFamine(); + news = state.news.filter((entry) => entry.type === NEWS_FAMINE); + this.assertEqual(news.length, 2, "a new famine is announced afresh"); + } + + test_the_snapshot_ships_the_famine() { + const state = smallState(); + const city = cityOf(state, 0); + starve(state, city); + state._tickFamine(); + const snapshot = state.snapshot(0); + const stats = snapshot.cityStats.find((entry) => entry.id === city.id); + this.assertTrue(stats.famine, "the famine is shipped to the client"); + } + + test_a_drained_food_store_starts_a_famine() { + const state = smallState(); + const city = cityOf(state, 0); + // The region's people far outnumber what its land can feed, so the day's + // harvest and every reachable store fall short of the need. + for (const coords of state.regionTiles(city)) { + const k = key(coords.x, coords.y); + state.tilePopulation.set(k, (state.tilePopulation.get(k) || 0) * 1000); + } + state._tickResources(); + this.assertTrue(state.isFamine(city), "the resource tick starts the famine"); + this.assertGreater( + state.news.filter((entry) => entry.type === NEWS_FAMINE).length, + 0, + "the world is told" + ); + } +} diff --git a/tests/modals_test.js b/tests/modals_test.js index d844154..bc824b2 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -230,6 +230,26 @@ export class CityModalTest extends TestCase { } } + async test_the_people_tab_warns_of_famine() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.show({ id: 1, name: "Paris" }, { + budget: 1.0e15, + buildings: BUILDINGS, + levels: {}, + ethnicMakeup: [], + famine: true, + }); + this.assertTrue( + env.$("#city-people-list").text().includes("Famine"), + "the famine warning is shown" + ); + } finally { + teardownDom(env); + } + } + async test_tabs_switch_the_visible_page() { const env = await setupDom(); try { @@ -1060,5 +1080,9 @@ export class NewsModalTest extends TestCase { describeNews({ type: "technology", civ: 2, name: "Radio" }, civs), "New Zealand scientists have discovered Radio" ); + this.assertEqual( + describeNews({ type: "famine", civ: 1, region: "Hanoi" }, civs), + "the Vietnamese are starving in Hanoi: a famine has begun" + ); } }