diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 0ccd314..0db7ecf 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "c8260b2", + "date": "2026-09-22", + "subject": "Better tile building management", + "body": "" + }, { "hash": "7c28c45", "date": "2026-09-22", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-22", "subject": "Gave regions their own economies and removed the global market", "body": "- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.\n- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.\n- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.\n- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.\n- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.\n- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption." - }, - { - "hash": "e7ee5db", - "date": "2026-09-22", - "subject": "Added the resource economy, its works and their icons", - "body": "- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.\n- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.\n- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated." } ]; diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 148e87c..e41c016 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -1023,7 +1023,7 @@ export const panelMethods = { $el.append( $("
").text( "The market is above the budgeted prices. " + - "The budget rises 10% a day until the materials can be bought." + "The budget rises 1% a day, doubling each day, until the materials can be bought." ) ); } else { @@ -1094,6 +1094,15 @@ export const panelMethods = { const building = entry.kind === "building"; const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex]; const name = proto ? proto.name : (building ? "Building" : "Unit"); + if (building && entry.phase === "materials") { + const $bar = $(""); + $bar.append($("").css("width", `${this._gatherFraction(entry) * 100}%`)); + $el.append( + $("").text(`Gathering materials for ${name}`), + $bar + ); + return; + } const verb = building ? "Building" : "Training"; const total = Math.max(entry.totalHours || 0, 0.0001); const elapsed = entry.elapsedHours || 0; @@ -1225,7 +1234,7 @@ export const panelMethods = { for (const improvement of TILE_IMPROVEMENTS) { if (improvement.coastal && !this._tileIsCoastal(coords)) continue; const built = !!(standing && standing.id === improvement.id); - const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed); + const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + speed); const title = standing && !built ? `${improvement.name} — another improvement already stands here` : `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` + @@ -1297,7 +1306,7 @@ export const panelMethods = { for (const producer of RESOURCE_BUILDINGS) { if (producer.coastal && !this._tileIsCoastal(coords)) continue; const built = !!(standing && standing.id === producer.id); - const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed); + const buildHours = ECONOMY.buildingHours(gdp, producer.buildCost) / (1 + speed); // Some producers are locked behind a technology (the fusion plant). const techIndex = producer.requiresTechnology ? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology) @@ -1847,6 +1856,13 @@ export const panelMethods = { const building = entry.kind === "building"; const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex]; const name = proto ? proto.name : (building ? "building" : "unit"); + // A building order spends its first days buying its steel and high-tech, so + // show that gathering rather than a construction clock that has not started. + if (building && entry.phase === "materials") { + this._trainingCityId = this.selectedCityId; + this.cityModal.setGatheringProgress(name, this._gatherFraction(entry)); + return; + } const verb = building ? "Building" : "Training"; if (this._trainingCityId !== this.selectedCityId) { this._trainingCityId = this.selectedCityId; @@ -1856,6 +1872,20 @@ export const panelMethods = { } }, + // How far a building order has got through gathering its material bill, as the + // average of each storable material's share bought. 1 when nothing is needed. + _gatherFraction(entry) { + let sum = 0; + let count = 0; + for (const id of ["steel", "hightech"]) { + const need = (entry.needed && entry.needed[id]) || 0; + if (!(need > 0)) continue; + sum += Math.min(1, ((entry.bought && entry.bought[id]) || 0) / need); + count += 1; + } + return count > 0 ? sum / count : 1; + }, + // How many levels of each building are already waiting in the city's shared // production queue, so the buildings tab prices the next order correctly. _queuedBuildingLevels(cityId) { diff --git a/client/js/modals/city.js b/client/js/modals/city.js index b8c4ae6..5000256 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -391,10 +391,15 @@ export class CityModal { for (const index of group.indices) { const entry = queue[index]; const elapsed = index === 0 ? entry.elapsedHours || 0 : 0; - cumulative += Math.max(entry.totalHours - elapsed, 0); + // A building still gathering its materials has not started building, so + // it adds no time to the queue's readiness estimate. + const gather = entry.kind === "building" && entry.phase === "materials"; + cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0); if (index === group.indices[0]) firstReady = cumulative; } const count = group.indices.length; + const gathering = queue[group.indices[0]].kind === "building" && + queue[group.indices[0]].phase === "materials"; const name = group.kind === "building" ? this.buildingNames.get(group.protoIndex) || "Building" : this.protoNames.get(group.protoIndex) || "Unit"; @@ -404,9 +409,11 @@ export class CityModal { $info.append($("").text(`${groupIndex + 1}. ${label}`)); $info.append( $("").text( - count > 1 - ? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}` - : `Ready in ${hours(cumulative)}` + gathering + ? "Gathering materials" + : count > 1 + ? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}` + : `Ready in ${hours(cumulative)}` ) ); const $actions = $(""); @@ -435,6 +442,15 @@ export class CityModal { this.$trainProgress.css("width", "100%"); } + // The selected city's head of queue is a building still buying its steel and + // high-tech: show how far that gathering has got, with no hours figure since + // the construction clock has not started. + setGatheringProgress(name, fraction = 0) { + this.$trainLabel.text(`Gathering materials for ${name}…`); + this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`); + this.$trainStatus.removeClass("hidden"); + } + // ------------------------------------------------------------- buildings -- _makeBuildingRow(proto, index) { @@ -495,7 +511,7 @@ export class CityModal { const parts = []; if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`); const cost = buildingBuildCost(row.proto, nextLevel); - const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor(); + const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor(); parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`); const requirement = this._buildRequirement(row.proto); if (requirement) parts.push(requirement); diff --git a/shared/data/economy.js b/shared/data/economy.js index c0b525f..68a019b 100644 --- a/shared/data/economy.js +++ b/shared/data/economy.js @@ -86,6 +86,10 @@ export const ECONOMY = { capitalTileProductionMultiplier: 2.5, citySurroundingProductionBonus: 0.3, gdpPerProductionPoint: 100_000, + // Building and tile-improvement construction runs this much faster than + // training: a building takes a tenth of the hours a unit of the same cost + // would. Unit training is untouched. + buildingTimeMultiplier: 0.1, // A nation opens the game on 1 January 2000 with a tenth of its starting GDP. startingBudgetFraction: 0.1, startingBudget: 1_000_000_000, @@ -106,6 +110,12 @@ export const ECONOMY = { return cost / capacity; }, + // The hours a building or tile improvement of `cost` takes to rise, ten times + // quicker than training something of the same cost. + buildingHours(gdp, cost) { + return this.trainingHours(gdp, cost) * this.buildingTimeMultiplier; + }, + populationGrowthFactor(hoursPerYear, hours = 1) { if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0; return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1; diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index 3951f6a..9fb7dd7 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -923,12 +923,22 @@ export const economyMethods = { _tickTraining() { const completed = []; + const day = Math.floor(this.totalHours / HOURS_PER_DAY); for (const [cityId, queue] of this.training) { const city = this.findCity(cityId); if (!city) { completed.push(cityId); continue; } + // A queued building first gathers its materials, once a day, exactly as a + // tile improvement's construction site does; only then does its + // construction time begin. + for (const entry of queue) { + if (entry.kind !== "building" || entry.phase !== "materials") continue; + if (entry.lastGatherDay === day) continue; + entry.lastGatherDay = day; + this._gatherBuildEntry(city, entry); + } // A city runs one production line per kind plus whatever its Barracks // (units) and Shipyards (buildings) add. Each line works one entry at full // capacity, so a higher-level city produces several things at once; the @@ -970,6 +980,12 @@ export const economyMethods = { i += 1; continue; } + // A building still gathering its materials is not on a construction line + // yet, so it neither works nor blocks the ones that are. + if (entry.phase === "materials") { + i += 1; + continue; + } const proto = this._queueEntryProto(entry); if (!proto) { queue.splice(i, 1); @@ -1006,9 +1022,11 @@ export const economyMethods = { return index >= 0 && index < this.protoUnits.length ? this.protoUnits[index] : null; }, - // Raises one level of a completed building. The cost was paid when the order - // was placed, so this only changes the city and invalidates the GDP cache. + // Raises one level of a completed building. The money cost was paid when the + // order was placed and the materials gathered as it ran, so this only returns + // any unspent material budget, changes the city and invalidates the GDP cache. _completeBuilding(city, entry) { + this._releaseEntryBudget(city.civ, entry); const level = this.getCityBuildingLevel(city, entry.protoIndex); city.buildings[entry.protoIndex] = level + 1; this._gdpPerCapitaCache.clear(); diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js index c2974f2..b83290c 100644 --- a/shared/game_state/orders.js +++ b/shared/game_state/orders.js @@ -7,7 +7,7 @@ import { GOVERNMENTS } from "../data/governments.js"; import { PILLAGE, pillageSteps } from "../data/combat.js"; import { transportImprovement } from "../data/improvements.js"; import { buildingBuildCost } from "../rules.js"; -import { TRAINING_QUEUE_LIMIT } from "./constants.js"; +import { TRAINING_QUEUE_LIMIT, HOURS_PER_DAY } from "./constants.js"; export const orderMethods = { // Adds one unit to the city's production queue. The queue is shared with @@ -73,10 +73,12 @@ export const orderMethods = { return true; }, - // Queues one more level of a building in the city's production queue. Like a - // unit, the level is paid for up front from the budget and only rises once - // its production time has elapsed. Repeated orders stack levels: the second - // queued level is priced off the level the first will leave behind. + // Queues one more level of a building in the city's production queue. The + // level's money cost is paid up front, but its steel and high-tech are + // gathered over the days that follow -- exactly as a tile improvement does -- + // and only then does the construction time begin. Repeated orders stack + // levels: the second queued level is priced off the level the first will + // leave behind. requestBuild(cityId, protoIndex, free = false) { const city = this.findCity(cityId); if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; @@ -113,23 +115,39 @@ export const orderMethods = { const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ)); if (capacity <= 0) return false; if (!this._spendBudget(city.civ, cost, "construction")) return false; + // The level's material bill is not bought up front any more: like a tile + // improvement, the order reserves a budget and gathers the steel and + // high-tech over the days before construction can begin. const materials = this.constructionResourceCost(proto, level, cost); - if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) { - this._refundBudget(city.civ, cost, "construction"); - return false; - } + const needed = { + steel: Math.max(0, materials.steel || 0), + hightech: Math.max(0, materials.hightech || 0), + }; + const reserved = needed.steel * this.getResourcePrice("steel") + + needed.hightech * this.getResourcePrice("hightech"); + this._reserveSiteBudget(city.civ, reserved, "construction"); const entry = { kind: "building", protoIndex, level, capacity, + phase: "materials", + budget: reserved, + prices: this._lockedUnitPrices(), + needed, + bought: { steel: 0, hightech: 0 }, elapsedHours: 0, - totalHours: (cost / capacity) * + totalHours: ECONOMY.buildingHours(this.getPlayerGdp(city.civ), cost) * this.constructionApprovalMultiplier(city) * this.constructionSpeedMultiplier(city.civ), + lastGatherDay: -1, }; if (queue) queue.push(entry); else this.training.set(cityId, [entry]); + // Make the first purchase at once, so a well-supplied city starts building + // right away instead of waiting for the first day boundary. + entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY); + this._gatherBuildEntry(city, entry); this._emitChanged(); return true; }, diff --git a/shared/game_state/sites.js b/shared/game_state/sites.js index 12a07c5..9fefd1f 100644 --- a/shared/game_state/sites.js +++ b/shared/game_state/sites.js @@ -10,11 +10,12 @@ // // A site stalls when a whole day passes without buying a needed material at the // locked price (the market moved against it). Rather than waiting for a manual -// top-up, it raises its budgeted prices -- and the money behind them -- by a -// tenth a day until it can buy; only when no supplier is within reach at all -// does it keep a lasting warning. The owner may still cancel the work, losing -// everything already spent. Production buildings that finish become private -// agents and step through the same phases again when they upgrade. +// top-up, it raises its budgeted prices -- and the money behind them -- each +// day, 1% at first and doubling daily, until it can buy; only when no supplier +// is within reach at all does it keep a lasting warning. The owner may still +// cancel the work, losing everything already spent. Production buildings that +// finish become private agents and step through the same phases again when they +// upgrade. import { key } from "../hex.js"; import { ECONOMY } from "../data/economy.js"; @@ -29,9 +30,10 @@ import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; // locked price is not bought from: the site waits for the market or raises its // own budget. const PRICE_TOLERANCE = 0.01; -// Each day a stalled site cannot buy, its budgeted prices and the matching -// money rise by this factor, so it bids its way up to the market unaided. -const BUDGET_DAILY_GROWTH = 1.1; +// The first day a stalled site cannot buy, its budgeted prices and the matching +// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so +// it bids its way up to the market unaided. +const BUDGET_DAILY_RISE = 0.01; const MATERIAL_IDS = ["steel", "hightech"]; const RESOURCE_IDS = ["steel", "hightech", "energy"]; @@ -126,10 +128,8 @@ export const siteMethods = { : tileImprovementResourceCost(proto, buildCost); const needed = emptyResources(); for (const id of RESOURCE_IDS) needed[id] = Math.max(0, materials[id] || 0); - const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(civ)); - const baseHours = kind === "upgrade" - ? (capacity > 0 ? Math.max(1, buildCost / capacity) : 1) - : ECONOMY.trainingHours(this.getPlayerGdp(civ), buildCost); + const gdp = this.getPlayerGdp(civ); + const baseHours = ECONOMY.buildingHours(gdp, buildCost); const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ)); const route = this._siteCityPath(civ, coords); const site = { @@ -148,6 +148,8 @@ export const siteMethods = { cityId: route.cityId, path: route.path, stalled: false, + // Consecutive stalled days, driving the doubling budget rise. + budgetRaises: 0, lastBuyDay: -1, }; this.constructionSites.set(k, site); @@ -229,6 +231,7 @@ export const siteMethods = { const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9); if (!stillMissing) { site.stalled = false; + site.budgetRaises = 0; site.phase = "construction"; site.elapsedHours = 0; this._emitChanged(); @@ -239,17 +242,23 @@ export const siteMethods = { site.stalled = stalled; this._emitChanged(); } + if (!stalled) site.budgetRaises = 0; }, // A day that bought nothing while a supplier is within reach raises the site's - // budgeted prices by a tenth and moves the matching money out of the treasury, - // then tries again. A supplier price above the raised cap simply stalls it - // once more and the budget climbs another tenth tomorrow. When no supplier can - // reach the site at any price the rise cannot help, so it is left alone. + // budgeted prices and moves the matching money out of the treasury, then tries + // again. The first day's rise is 1% and it doubles every day after (2%, 4%, + // 8%, ...), so a stubborn price is met within a few days. A supplier still + // above the raised cap simply stalls it once more; the count resets as soon as + // it buys. When no supplier can reach the site at any price the rise cannot + // help, so it is left alone. _raiseSiteBudgetIfStalled(site) { if (!site.stalled || site.phase !== "materials") return; if (this._siteUnreachableMaterials(site).length > 0) return; - for (const id of RESOURCE_IDS) site.prices[id] *= BUDGET_DAILY_GROWTH; + const raises = site.budgetRaises || 0; + const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises; + for (const id of RESOURCE_IDS) site.prices[id] *= factor; + site.budgetRaises = raises + 1; const add = this._siteTopUp(site, site.prices); this._reserveSiteBudget(site.civ, add); site.budget += add; @@ -325,6 +334,70 @@ export const siteMethods = { return payer; }, + // ------------------------------------------------ city building orders -- + + // One day of a queued city building's material phase. Like a construction + // site it buys whatever of its steel and high-tech bill it still lacks, at the + // prices locked in when the order was placed: the nation's own stores first, + // then the reachable suppliers. It only flips to construction once the whole + // bill is gathered; a day that buys nothing leaves it stalled. + _gatherBuildEntry(city, entry) { + const payer = this._entryPayer(city, entry); + const remaining = { steel: 0, hightech: 0 }; + for (const id of MATERIAL_IDS) { + remaining[id] = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0)); + } + // The nation's own cities are bought from first, nearest to the building. + const drawn = this._drawFromNearest(city.civ, city.coords, remaining, payer); + for (const id of MATERIAL_IDS) { + if (drawn[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + drawn[id]; + } + for (const id of MATERIAL_IDS) { + const missing = (entry.needed[id] || 0) - (entry.bought[id] || 0); + if (!(missing > 1e-9)) continue; + const temp = emptyResources(); + this._procureFromNeighbours(city, id, missing, temp, this._resourceNodes(), { + payer, + allowSea: this._cityMarketAccess(city), + }); + if (temp[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + temp[id]; + } + const stillMissing = MATERIAL_IDS.some( + (id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9 + ); + if (!stillMissing) entry.phase = "construction"; + entry.stalled = stillMissing; + return !stillMissing; + }, + + // A payer for a queued building: its reserved material budget first, then the + // public treasury, so an overrun is public money. + _entryPayer(city, entry) { + const payer = { total: 0, overflow: 0 }; + payer.charge = (cost) => { + if (!(cost > 0)) return; + const fromBudget = Math.min(entry.budget || 0, cost); + entry.budget = (entry.budget || 0) - fromBudget; + const rest = cost - fromBudget; + if (rest > 0) { + this.budgets.set(city.civ, this.getBudget(city.civ) - rest); + this._recordBudgetCash(city.civ, "construction", -rest); + payer.overflow += rest; + } + payer.total += cost; + }; + return payer; + }, + + // Hands a finished order's unspent material budget back to the treasury. The + // reserved money was never paid to the regions, so it is not clawed back. + _releaseEntryBudget(civ, entry) { + if (!(entry.budget > 0)) return; + this.budgets.set(civ, this.getBudget(civ) + entry.budget); + this._recordBudgetCash(civ, "construction", entry.budget); + entry.budget = 0; + }, + // -------------------------------------------------- completion -- _completeConstructionSite(site) { diff --git a/tests/construction_site_test.js b/tests/construction_site_test.js index bce4069..d77fec0 100644 --- a/tests/construction_site_test.js +++ b/tests/construction_site_test.js @@ -37,6 +37,7 @@ function makeSite(state, coords, overrides = {}) { cityId: null, path: [], stalled: false, + budgetRaises: 0, lastBuyDay: -1, ...overrides, }; @@ -161,14 +162,46 @@ export class ConstructionSiteTest extends TestCase { state._raiseSiteBudgetIfStalled(site); this.assertApprox( site.prices.steel, - locked * 1.1, + locked * 1.01, 1e-6, - "the budgeted price rises a tenth" + "the first day's budgeted price rises 1%" ); this.assertGreater(site.budget, 0, "the matching money moved into the site"); this.assertLess(state.getBudget(0), treasury, "and came from the treasury"); } + test_the_budget_rise_doubles_every_day() { + const state = smallState(); + clearMaterials(state); + const city = cityOf(state, 0); + const coords = bareOwnedLand(state); + const site = makeSite(state, coords, { + cityId: city.id, + budget: 0, + needed: { steel: 100, hightech: 0, energy: 0 }, + }); + // A supplier far out of reach in price keeps the site stalling as it climbs. + site.prices.steel = state._marketUnitCost("steel") * 0.001; + for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9; + const nodes = state._resourceNodes(); + const suppliers = state._nearbySuppliers(city, "steel", nodes); + this.assertGreater(suppliers.length, 0, "a supplier is reachable"); + for (const other of state.cities) state.getCityResourceStock(other).steel = 0; + suppliers[0].stock.steel = 1e9; + state.budgets.set(0, 1e15); + state._gatherSiteMaterials(site); + this.assertTrue(site.stalled, "the locked price is too low to buy at"); + // The rises compound: 1%, then 2%, then 4%, ... + let prev = site.prices.steel; + for (let day = 0; day < 4 && site.phase === "materials"; day++) { + state._raiseSiteBudgetIfStalled(site); + const expected = prev * (1 + 0.01 * 2 ** day); + this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`); + prev = site.prices.steel; + } + } + + test_a_stalled_budget_climbs_until_a_supplier_is_affordable() { const state = smallState(); clearMaterials(state); @@ -195,9 +228,9 @@ export class ConstructionSiteTest extends TestCase { state._raiseSiteBudgetIfStalled(site); this.assertApprox( site.prices.steel, - locked * 1.1, + locked * 1.01, 1e-6, - "the budgeted price rose a tenth" + "the first day's budgeted price rises 1%" ); // Keep letting the budget climb; it must eventually reach the supplier. for (let day = 0; day < 60 && site.phase === "materials"; day++) { diff --git a/tests/economy_test.js b/tests/economy_test.js index b1d68a9..57c20b2 100644 --- a/tests/economy_test.js +++ b/tests/economy_test.js @@ -44,6 +44,23 @@ export class EconomyTest extends TestCase { this.assertEqual(config({ gdpPerProductionPoint: 0 }).trainingHours(1000, 50), 0); } + test_building_hours_are_a_tenth_of_training() { + const cfg = config({ gdpPerProductionPoint: 100 }); + this.assertApprox(cfg.buildingHours(1000, 50), 0.5); + this.assertApprox( + cfg.buildingHours(1000, 50), + cfg.trainingHours(1000, 50) / 10, + 1e-9, + "construction is ten times faster than training the same cost" + ); + } + + test_building_hours_follow_the_multiplier() { + const cfg = config({ gdpPerProductionPoint: 100, buildingTimeMultiplier: 0.5 }); + this.assertApprox(cfg.buildingHours(1000, 50), 2.5); + this.assertEqual(config({ gdpPerProductionPoint: 0 }).buildingHours(1000, 50), 0); + } + test_trade_tax_defaults() { this.assertApprox(TAXES.defaultSales, 0.2); this.assertApprox(TAXES.defaultExport, 0.05); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index be9efb8..1dcce89 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -1746,7 +1746,7 @@ export class GameScreenTest extends TestCase { .map((_, el) => env.$(el).text()) .get() .join("|"); - this.assertTrue(labels.includes("10%"), `the automatic rise is explained: ${labels}`); + this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`); this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`); this.assertEqual( env.$("#tile-works .site-warning").length, diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index d249a49..dc5b196 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -3,7 +3,7 @@ import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/help import { key } from "../shared/hex.js"; import { EFFECT_RESEARCH } from "../shared/data.js"; import { tileFoodOutput } from "../shared/resources.js"; -import { DAYS_PER_YEAR } from "../shared/game_state/constants.js"; +import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js"; const BIG_BUDGET = 1.0e15; @@ -182,6 +182,51 @@ export class GameStateBuildingsTest extends TestCase { this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied"); } + test_a_building_order_gathers_its_materials_before_building() { + const state = smallState(); + state.budgets.set(0, BIG_BUDGET); + const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); + const city = cityOf(state, 0); + // Drain every own store and cut the network, so nothing can be gathered. + for (const own of state.cities) { + if (own.civ !== 0) continue; + const store = state.getCityResourceStock(own); + store.steel = 0; + store.hightech = 0; + } + state.roads.clear(); + state.railways.clear(); + this.assertTrue(state.requestBuild(city.id, index)); + const queue = state.getTraining(city.id); + this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds"); + this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started"); + // Supply the steel; the next day the order can buy and start building. + state.getCityResourceStock(city).steel = 1.0e9; + for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour(); + this.assertEqual(queue[0].phase, "construction", "the bill is gathered"); + this.advanceUntilIdle(state); + this.assertEqual(state.getCityBuildingLevel(city, index), 1); + } + + test_a_stalled_building_order_waits_for_materials() { + const state = smallState(); + state.budgets.set(0, BIG_BUDGET); + const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 })); + const city = cityOf(state, 0); + // Nothing anywhere to buy: the order can only wait. + for (const own of state.cities) { + const store = state.getCityResourceStock(own); + store.steel = 0; + store.hightech = 0; + } + this.assertTrue(state.requestBuild(city.id, index)); + for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour(); + const entry = state.getTraining(city.id)[0]; + this.assertEqual(entry.phase, "materials", "it is still gathering"); + this.assertTrue(entry.stalled, "the entry reports being stalled"); + this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built"); + } + test_building_and_unit_share_one_queue_and_are_produced_in_order() { const state = smallState(); state.budgets.set(0, BIG_BUDGET);