Reworked building costs

This commit is contained in:
2026-09-17 22:48:16 +02:00
parent 168c365b87
commit b9527e004b
12 changed files with 75 additions and 120 deletions
-2
View File
@@ -442,7 +442,6 @@ export const panelMethods = {
this.trainModal.close();
this.buildingsModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0,
BUILDINGS,
this._cityBuildings.get(city.id) || {}
@@ -453,7 +452,6 @@ export const panelMethods = {
if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return;
const stats = this.snapshot.viewerStats || {};
this.buildingsModal.refresh(
stats.gdp || 0,
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {}
);
+9 -15
View File
@@ -27,14 +27,14 @@ export class BuildingsModal {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, budget, buildings, levels) {
show(city, budget, buildings, levels) {
this.cityId = city.id;
this.cityName = city.name;
this.buildings = buildings;
this.$title.text(`${city.name} — Buildings`);
this.$list.empty();
this.rows = buildings.map((proto, index) => this._makeRow(proto, index));
this.refresh(gdp, budget, levels);
this.refresh(budget, levels);
this.$modal.removeClass("hidden");
}
@@ -57,27 +57,21 @@ export class BuildingsModal {
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
refresh(gdp, budget, levels) {
refresh(budget, levels) {
this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.rows) {
const level = levels[row.index] || 0;
row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`);
row.$name.text(`${row.proto.name} — Level ${level}`);
row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1));
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`);
if (level < row.proto.maxLevel) {
parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`);
}
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, level)))}`);
row.$cost.text(parts.join(" · "));
if (level < row.proto.maxLevel) {
const cost = buildingBuildCost(row.proto, gdp, level);
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
} else {
row.$upgrade.text("Max level").prop("disabled", true);
}
const cost = buildingBuildCost(row.proto, level);
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`)
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
.prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
+9 -38
View File
@@ -1,5 +1,6 @@
// Buildings that can be raised in a city. Costs and upkeep scale with the
// city's share of national GDP, so early levels are cheap and later ones bite.
// Buildings that can be raised in a city. Build costs are fixed and grow only
// with the level, with no level cap; upkeep is 1% of that cost, so it follows
// the level too.
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
@@ -8,13 +9,8 @@ export const BUILDINGS = [
id: "market",
name: "Market",
description: "A hub of local trade. Raises national GDP, with diminishing returns.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.01,
baseCost: 5_000_000,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00003,
upkeepAlpha: 1.5,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
},
@@ -22,13 +18,8 @@ export const BUILDINGS = [
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises budget income.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
baseCost: 10_000_000,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00004,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
},
@@ -36,13 +27,8 @@ export const BUILDINGS = [
id: "research_lab",
name: "Research lab",
description: "Applies science to industry and government. Produces research points.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.015,
baseCost: 100_000_000,
costAlpha: 1.65,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
},
@@ -50,13 +36,8 @@ export const BUILDINGS = [
id: "university",
name: "University",
description: "Higher education produces research and a little culture.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.025,
baseCost: 100_000_000,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00008,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
},
@@ -64,13 +45,8 @@ export const BUILDINGS = [
id: "museum",
name: "Museum",
description: "Preserves national heritage, strengthening culture against foreign ideas.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
baseCost: 50_000_000,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
},
@@ -78,13 +54,8 @@ export const BUILDINGS = [
id: "cinema_studio",
name: "Cinema studio",
description: "Mass media spreads the nation's culture at home and abroad.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.03,
baseCost: 50_000_000,
costAlpha: 1.75,
baseUpkeep: 0,
upkeepGdpFraction: 0.0001,
upkeepAlpha: 1.65,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
},
+8 -9
View File
@@ -87,8 +87,8 @@ export const economyMethods = {
return this._playerAggregates(civ).gdp;
},
getPlayerUpkeep(civ, gdp = -1) {
let total = this.getBuildingUpkeep(civ, gdp);
getPlayerUpkeep(civ) {
let total = this.getBuildingUpkeep(civ);
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
@@ -100,14 +100,13 @@ export const economyMethods = {
return total;
},
getBuildingUpkeep(civ, gdp = -1) {
getBuildingUpkeep(civ) {
let total = 0;
if (gdp < 0) gdp = this.getPlayerGdp(civ);
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
total += buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
total += buildingUpkeep(this.protoBuildings[index], city.buildings[indexStr]);
}
}
return total;
@@ -127,7 +126,7 @@ export const economyMethods = {
const incomeSources = this._incomeSources(civ);
const modifier = incomeSources.reduce((sum, source) => sum + source.effect, 0);
const income = base * (1 + modifier);
const buildingSources = this._buildingUpkeepSources(civ, gdp);
const buildingSources = this._buildingUpkeepSources(civ);
const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.amount, 0);
const unitSources = this._unitUpkeepSources(civ);
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
@@ -182,13 +181,13 @@ export const economyMethods = {
return this._sortSources(Array.from(sources.values()));
},
_buildingUpkeepSources(civ, gdp) {
_buildingUpkeepSources(civ) {
const sources = new Map();
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const amount = buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
const amount = buildingUpkeep(this.protoBuildings[index], city.buildings[indexStr]);
if (amount === 0) continue;
const entry = sources.get(index) || { label: this.protoBuildings[index].name, kind: "building", amount: 0 };
entry.amount += amount;
@@ -322,7 +321,7 @@ export const economyMethods = {
const gdp = this.getPlayerGdp(civ);
const modifiers = this.getCivModifiers(civ);
const income = ECONOMY.budgetIncome(gdp, hours) * (1 + (modifiers[EFFECT_BUDGET_INCOME] || 0));
const upkeep = this.getPlayerUpkeep(civ, gdp) * hours;
const upkeep = this.getPlayerUpkeep(civ) * hours;
this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
}
},
+2 -4
View File
@@ -56,9 +56,7 @@ export const orderMethods = {
const proto = this.protoBuildings[protoIndex];
const civ = city.civ;
const level = this.getCityBuildingLevel(city, protoIndex);
if (level >= proto.maxLevel) return false;
const gdp = this.getPlayerGdp(civ);
const cost = buildingBuildCost(proto, gdp, level);
const cost = buildingBuildCost(proto, level);
if (cost > 0 && this.getBudget(civ) < cost) return false;
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
city.buildings[protoIndex] = level + 1;
@@ -73,7 +71,7 @@ export const orderMethods = {
const civ = city.civ;
const level = this.getCityBuildingLevel(city, protoIndex);
if (level <= 0) return false;
const refund = buildingRefund(proto, this.getPlayerGdp(civ), level);
const refund = buildingRefund(proto, level);
if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund);
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
+1 -1
View File
@@ -62,7 +62,7 @@ export const serializationMethods = {
return {
...headline,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv, headline.gdp),
upkeep: this.getPlayerUpkeep(viewerCiv),
research: this.getResearch(viewerCiv),
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
+12 -8
View File
@@ -3,19 +3,23 @@
import { effectValue, CITY_DEFENSE_BONUS } from "./data.js";
export function buildingBuildCost(proto, gdp, level) {
return (proto.baseCost + proto.costGdpFraction * Math.max(gdp, 0)) *
Math.pow(proto.costAlpha, Math.max(level, 0));
// Fixed cost of raising a building to `level` (0-indexed: the cost of the next
// level). It grows only with the level, never with the nation's GDP.
export function buildingBuildCost(proto, level) {
return proto.baseCost * Math.pow(proto.costAlpha, Math.max(level, 0));
}
export function buildingRefund(proto, gdp, level) {
return buildingBuildCost(proto, gdp, level - 1) * proto.refundFraction;
export function buildingRefund(proto, level) {
return buildingBuildCost(proto, level - 1) * proto.refundFraction;
}
export function buildingUpkeep(proto, gdp, level) {
// Recurring upkeep of a building kept at `level`, as a fraction of the cost of
// that level. Like the build cost it follows the level alone, never the GDP.
export const BUILDING_UPKEEP_FRACTION = 0.01;
export function buildingUpkeep(proto, level) {
if (level <= 0) return 0;
const base = proto.baseUpkeep + proto.upkeepGdpFraction * Math.max(gdp, 0);
return base * Math.pow(proto.upkeepAlpha, level);
return buildingBuildCost(proto, level - 1) * BUILDING_UPKEEP_FRACTION;
}
export function buildingEffectValue(proto, stat, level) {
-5
View File
@@ -93,13 +93,8 @@ export function gdpBuilding(overrides = {}) {
return {
id: "test_market",
name: "Test market",
maxLevel: 10,
baseCost: 100,
costGdpFraction: 0.001,
costAlpha: 2.0,
baseUpkeep: 0,
upkeepGdpFraction: 0,
upkeepAlpha: 2.0,
refundFraction: 0.5,
effects: [{ stat: "gdp", baseValue: 0.5, exponent: 1.0 }],
...overrides,
+10 -9
View File
@@ -30,13 +30,14 @@ export class GameStateBuildingsTest extends TestCase {
this.assertFalse(state.requestBuild(cityOf(state, 0).id, index));
}
test_request_build_respects_max_level() {
test_request_build_has_no_level_cap() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(state, gdpBuilding({ maxLevel: 1 }));
const cityId = cityOf(state, 0).id;
this.assertTrue(state.requestBuild(cityId, index));
this.assertFalse(state.requestBuild(cityId, index), "already at max level");
const index = this.addBuilding(state, gdpBuilding());
const city = cityOf(state, 0);
for (let i = 0; i < 20; i++) this.assertTrue(state.requestBuild(city.id, index));
this.assertEqual(state.getCityBuildingLevel(city, index), 20);
this.assertTrue(state.requestBuild(city.id, index), "still upgradable past the old cap");
}
test_request_build_rejects_unknown_targets() {
@@ -67,7 +68,7 @@ export class GameStateBuildingsTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 0, costGdpFraction: 0, baseUpkeep: 100, upkeepAlpha: 1.0 })
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0 })
);
const before = state.getPlayerUpkeep(0);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
@@ -77,7 +78,7 @@ export class GameStateBuildingsTest extends TestCase {
test_research_buildings_accumulate_points_each_hour() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const building = gdpBuilding({ baseCost: 0, costGdpFraction: 0, effects: [] });
const building = gdpBuilding({ baseCost: 0, effects: [] });
building.effects.push({ stat: EFFECT_RESEARCH, baseValue: 2.0, exponent: 1.0 });
const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
@@ -95,7 +96,7 @@ export class GameStateBuildingsTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 0, costGdpFraction: 0, baseUpkeep: 100, upkeepAlpha: 1.0, effects: [] })
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0, effects: [] })
);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
const breakdown = state.viewerStats(0).breakdown;
@@ -117,7 +118,7 @@ export class GameStateBuildingsTest extends TestCase {
const effect = { stat: EFFECT_BUDGET_INCOME, baseValue: 0.05, exponent: 1.0 };
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 0, costGdpFraction: 0, baseUpkeep: 0, effects: [effect] })
gdpBuilding({ baseCost: 0, effects: [effect] })
);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
const breakdown = state.viewerStats(0).breakdown;
+3 -3
View File
@@ -86,7 +86,7 @@ export class BuildingsModalTest extends TestCase {
modal.onDemolish = (cityId, index) => {
demolished = [cityId, index];
};
modal.show({ id: 3, name: "Lyon" }, 1.0e9, 1.0e15, BUILDINGS, {});
modal.show({ id: 3, name: "Lyon" }, 1.0e15, BUILDINGS, {});
this.assertEqual(env.$("#buildings-title").text(), "Lyon — Buildings");
this.assertSize(env.$("#buildings-list .row-card"), BUILDINGS.length);
const first = env.$("#buildings-list .row-card").first();
@@ -95,7 +95,7 @@ export class BuildingsModalTest extends TestCase {
first.find(".actions button").first().click();
this.assertEqual(built, [3, 0]);
modal.refresh(1.0e9, 1.0e15, { 0: 1 });
modal.refresh(1.0e15, { 0: 1 });
this.assertTrue(first.find(".name").text().includes("Level 1"));
first.find(".actions button").eq(1).click();
this.assertEqual(demolished, [3, 0]);
@@ -110,7 +110,7 @@ export class BuildingsModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new BuildingsModal();
modal.show({ id: 1, name: "X" }, 1.0e9, 0, BUILDINGS, {});
modal.show({ id: 1, name: "X" }, 0, BUILDINGS, {});
const upgrade = env.$("#buildings-list .row-card").first().find(".actions button").first();
this.assertTrue(upgrade.prop("disabled"));
} finally {
+5 -7
View File
@@ -94,14 +94,13 @@ export class EconomyOrderEdgesTest extends TestCase {
this.assertNull(state.getTraining(city.id), "nothing is left in the queue");
}
test_building_stops_at_the_maximum_level() {
test_buildings_have_no_level_cap() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoBuildings[0];
state.budgets.set(0, 1e15);
for (let i = 0; i < proto.maxLevel; i++) state.requestBuild(city.id, 0);
this.assertEqual(state.getCityBuildingLevel(city, 0), proto.maxLevel);
this.assertFalse(state.requestBuild(city.id, 0), "cannot build past the cap");
for (let i = 0; i < 12; i++) state.requestBuild(city.id, 0);
this.assertEqual(state.getCityBuildingLevel(city, 0), 12);
this.assertTrue(state.requestBuild(city.id, 0), "levels keep rising without a cap");
}
test_demolishing_removes_one_level_and_refunds() {
@@ -147,9 +146,8 @@ export class EconomyOrderEdgesTest extends TestCase {
test_buildings_in_the_catalogue_have_a_cost_curve() {
for (const proto of BUILDINGS) {
this.assertTrue(proto.costGdpFraction > 0, `${proto.id} cost fraction`);
this.assertTrue(proto.baseCost > 0, `${proto.id} base cost`);
this.assertTrue(proto.costAlpha > 1, `${proto.id} cost growth`);
this.assertTrue(proto.maxLevel >= 1, `${proto.id} max level`);
}
}
}
+16 -19
View File
@@ -25,11 +25,7 @@ import {
function proto(overrides = {}) {
return {
baseCost: 0,
costGdpFraction: 0,
costAlpha: 1.5,
baseUpkeep: 0,
upkeepGdpFraction: 0,
upkeepAlpha: 1.5,
refundFraction: 0.5,
effects: [],
...overrides,
@@ -38,29 +34,30 @@ function proto(overrides = {}) {
export class RulesTest extends TestCase {
test_build_cost_grows_exponentially_with_level() {
const building = proto({ baseCost: 100, costGdpFraction: 0.01, costAlpha: 2.0 });
this.assertApprox(buildingBuildCost(building, 1000, 0), 110);
this.assertApprox(buildingBuildCost(building, 1000, 1), 220);
this.assertApprox(buildingBuildCost(building, 1000, 2), 440);
const building = proto({ baseCost: 100, costAlpha: 2.0 });
this.assertApprox(buildingBuildCost(building, 0), 100);
this.assertApprox(buildingBuildCost(building, 1), 200);
this.assertApprox(buildingBuildCost(building, 2), 400);
}
test_build_cost_is_based_on_gdp() {
const building = proto({ costGdpFraction: 0.01 });
this.assertApprox(buildingBuildCost(building, 5000, 0), 50);
this.assertApprox(buildingBuildCost(building, 5000, 0) * 2, buildingBuildCost(building, 10000, 0));
test_build_cost_is_fixed_and_independent_of_gdp() {
const building = proto({ baseCost: 100, costAlpha: 1.5 });
this.assertApprox(buildingBuildCost(building, 0), 100);
this.assertApprox(buildingBuildCost(building, 3), 100 * Math.pow(1.5, 3));
}
test_upkeep_grows_exponentially_with_level() {
const building = proto({ baseUpkeep: 10, upkeepAlpha: 1.5 });
this.assertEqual(buildingUpkeep(building, 1000, 0), 0);
this.assertApprox(buildingUpkeep(building, 1000, 1), 15);
this.assertApprox(buildingUpkeep(building, 1000, 2), 22.5);
test_upkeep_is_one_percent_of_the_levels_build_cost() {
const building = proto({ baseCost: 100, costAlpha: 2.0 });
this.assertEqual(buildingUpkeep(building, 0), 0);
this.assertApprox(buildingUpkeep(building, 1), 1);
this.assertApprox(buildingUpkeep(building, 2), 2);
this.assertApprox(buildingUpkeep(building, 3), 4);
}
test_refund_is_a_fraction_of_the_removed_level() {
const building = proto({ baseCost: 100, refundFraction: 0.5 });
this.assertApprox(buildingRefund(building, 0, 1), 50);
this.assertApprox(buildingRefund(building, 0, 2), 75);
this.assertApprox(buildingRefund(building, 1), 50);
this.assertApprox(buildingRefund(building, 2), 75);
}
test_effect_is_linear_or_sublinear() {