diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 27a7c94..fb38515 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -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) || {} ); diff --git a/client/js/modals/buildings.js b/client/js/modals/buildings.js index e8b1296..929892b 100644 --- a/client/js/modals/buildings.js +++ b/client/js/modals/buildings.js @@ -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); diff --git a/shared/data/buildings.js b/shared/data/buildings.js index 31dc5cb..884a7bc 100644 --- a/shared/data/buildings.js +++ b/shared/data/buildings.js @@ -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)], }, diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 3db7352..f2d5075 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -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); } }, diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index 5e8de6e..55e46fd 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -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; diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index 93a167e..e24c5c2 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -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), diff --git a/shared/rules.js b/shared/rules.js index c0a8750..5b1eee6 100644 --- a/shared/rules.js +++ b/shared/rules.js @@ -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) { diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js index c4f7b31..41cfb4f 100644 --- a/tests/framework/helpers.js +++ b/tests/framework/helpers.js @@ -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, diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index 701903d..a796075 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -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; diff --git a/tests/modals_test.js b/tests/modals_test.js index b4d0f4c..ced0b78 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -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 { diff --git a/tests/regression_test.js b/tests/regression_test.js index 1744e9d..78b59cc 100644 --- a/tests/regression_test.js +++ b/tests/regression_test.js @@ -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`); } } } diff --git a/tests/rules_test.js b/tests/rules_test.js index af12851..1dda8ca 100644 --- a/tests/rules_test.js +++ b/tests/rules_test.js @@ -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() {