Started a famine when a region's food runs out

This commit is contained in:
2026-09-22 12:54:23 +02:00
parent b6287d7cf0
commit 238985b6fe
10 changed files with 194 additions and 6 deletions
+1
View File
@@ -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; }
+12 -2
View File
@@ -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");
}
+12 -3
View File
@@ -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(
$("<div class='desc famine-warning'></div>").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 {
+2
View File
@@ -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;
}
+2
View File
@@ -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";
+7
View File
@@ -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.
+51
View File
@@ -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;
+2 -1
View File
@@ -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),
+81
View File
@@ -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"
);
}
}
+24
View File
@@ -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"
);
}
}