Started a famine when a region's food runs out
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user