// Player-issued orders that are not movement: training, building, government, // research and pillaging. import { key } from "../hex.js"; import { ECONOMY } from "../data/economy.js"; 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 { tileImprovementResourceCost } from "../resources.js"; import { TRAINING_QUEUE_LIMIT } from "./constants.js"; export const orderMethods = { // Adds one unit to the city's production queue. The queue is shared with // buildings and holds at most TRAINING_QUEUE_LIMIT entries; the first entry // is produced while the rest wait. The unit's price is paid up front from the // budget, then it takes production capacity to finish. requestTrain(cityId, protoIndex, free = false) { const city = this.findCity(cityId); if (!city) return false; if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false; const proto = this.protoUnits[protoIndex]; // Aircraft and warships need the right installation in the city first. if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false; // The local testing shortcut: no cost, no production time, no queue. if (free) { this._spawnTrainedUnit(city, proto); this._visibilityDirty = true; this._emitChanged(); return true; } const queue = this.training.get(cityId); if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false; // A free unit (the worker) needs neither money nor production time. if (proto.cost <= 0) { this._spawnTrainedUnit(city, proto); this._visibilityDirty = true; this._emitChanged(); return true; } const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ)); if (capacity <= 0) return false; if (!this._spendBudget(city.civ, proto.cost, "training")) return false; const materials = this.constructionResourceCost(proto, 0, proto.cost); if (!this._payConstructionResources(city.civ, city.coords, materials, "training", proto.name)) { this._refundBudget(city.civ, proto.cost, "training"); return false; } const entry = { kind: "unit", protoIndex, capacity, elapsedHours: 0, totalHours: (proto.cost / capacity) * this.constructionApprovalMultiplier(city) * this.constructionSpeedMultiplier(city.civ), }; if (queue) queue.push(entry); else this.training.set(cityId, [entry]); this._emitChanged(); return true; }, // Cancels the queued entry at `index` in the city's production queue. // Nothing is refunded: the money and time already spent on it are lost. cancelTrain(cityId, index) { const queue = this.training.get(cityId); if (!queue) return false; const at = Number(index); if (!Number.isInteger(at) || at < 0 || at >= queue.length) return false; queue.splice(at, 1); if (queue.length === 0) this.training.delete(cityId); this._emitChanged(); 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. requestBuild(cityId, protoIndex, free = false) { const city = this.findCity(cityId); if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; const proto = this.protoBuildings[protoIndex]; // Placement rules: a port needs the sea, a shipyard needs a port. if (proto.coastal && !this.isCoastalCity(city)) return false; if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false; if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false; // The local testing shortcut: raise the next level at once, for free. if (free) { const level = this.getCityBuildingLevel(city, protoIndex) + this._pendingBuildingLevels(cityId, protoIndex); city.buildings[protoIndex] = level + 1; this._gdpPerCapitaCache.clear(); this._touchModifiers(); this._emitChanged(); return true; } const queue = this.training.get(cityId); if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false; const level = this.getCityBuildingLevel(city, protoIndex) + this._pendingBuildingLevels(cityId, protoIndex); const cost = buildingBuildCost(proto, level); // A free improvement is raised at once. if (cost <= 0) { city.buildings[protoIndex] = level + 1; this._gdpPerCapitaCache.clear(); this._touchModifiers(); this._emitChanged(); return true; } const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ)); if (capacity <= 0) return false; if (!this._spendBudget(city.civ, cost, "construction")) return false; 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 entry = { kind: "building", protoIndex, level, capacity, elapsedHours: 0, totalHours: (cost / capacity) * this.constructionApprovalMultiplier(city) * this.constructionSpeedMultiplier(city.civ), }; if (queue) queue.push(entry); else this.training.set(cityId, [entry]); this._emitChanged(); return true; }, // Lays a road or railway on a land tile the civilisation controls. A tile // holds at most one transport improvement, so building one replaces whatever // is already there; the full price is still paid. The cost is charged up front // and the tile changes at once, and afterwards the nation pays an hourly // maintenance for every road and railway tile it holds. requestBuildImprovement(civ, coords, id, free = false) { if (civ < 0 || civ >= this.civilisations.length) return false; const improvement = transportImprovement(id); if (!improvement || !coords) return false; if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false; const k = key(coords.x, coords.y); const tile = this.tiles[k]; if (!tile || tile.terrainClass !== "Land") return false; if (this.civAt(coords) !== civ) return false; const current = this.railways.has(k) ? "railway" : this.roads.has(k) ? "road" : null; if (current === improvement.id) return false; if (!free) { if (!this._spendBudget(civ, improvement.buildCost, "tilebuild")) return false; const materials = tileImprovementResourceCost(improvement); if (!this._payConstructionResources(civ, coords, materials, "tilebuild", improvement.name)) { this._refundBudget(civ, improvement.buildCost, "tilebuild"); return false; } } this.roads.delete(k); this.railways.delete(k); if (improvement.id === "railway") this.railways.add(k); else this.roads.add(k); this._improvementVersion += 1; this._emitChanged(); return true; }, // Removes whatever transport improvement stands on a land tile the // civilisation controls. Removal is immediate and free, so a mis-built road // can be cleared without a new one replacing it. requestRemoveImprovement(civ, coords) { if (civ < 0 || civ >= this.civilisations.length) return false; if (!coords) return false; if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false; const k = key(coords.x, coords.y); const tile = this.tiles[k]; if (!tile || tile.terrainClass !== "Land") return false; if (this.civAt(coords) !== civ) return false; const present = this.railways.has(k) || this.roads.has(k); if (!present) return false; this.roads.delete(k); this.railways.delete(k); this._improvementVersion += 1; this._emitChanged(); return true; }, requestDemolish(cityId, protoIndex) { const city = this.findCity(cityId); if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false; const level = this.getCityBuildingLevel(city, protoIndex); if (level <= 0) return false; // Downgrading refunds nothing: the money and materials already sunk into // the level are lost. if (level <= 1) delete city.buildings[protoIndex]; else city.buildings[protoIndex] = level - 1; this._gdpPerCapitaCache.clear(); this._touchModifiers(); this._emitChanged(); return true; }, requestSetGovernment(civ, index) { if (civ < 0 || civ >= this.civilisations.length) return false; if (index < 0 || index >= this.governments.length) return false; if (this.getGovernment(civ) === index) return false; const cost = GOVERNMENTS[index].baseCost + GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0); if (cost > 0) this._spendBudget(civ, cost, "campaign"); this.government.set(civ, index); this._gdpPerCapitaCache.clear(); this._touchModifiers(); this._emitChanged(); return true; }, // Focuses a nation's research on one technology. Every point it creates from // now on flows straight into it; nothing is charged up front and nothing is // banked while no technology is focused. requestFocusTechnology(civ, index) { if (!this.canResearchTechnology(civ, index)) return false; if (this.getFocusedTechnology(civ) === index) return true; // Progress is kept per technology, so switching focus never discards work // already done on either the old focus or the new one. this.focusTechnology.set(civ, index); this._emitChanged(); return true; }, // Whether the units may pillage the tile they stand on: all of them are on // one hostile tile where no battle is raging. canPillage(unitIds) { const units = this._unitsForOrder(unitIds); if (units.length === 0) return false; const coords = units[0].coords; const owner = this.civAt(coords); if (owner < 0 || owner === units[0].civ) return false; if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) { return false; } return !this._battleTiles().some( (battle) => battle.coords.x === coords.x && battle.coords.y === coords.y ); }, // Pillages the tile the units stand on. Every unit in the stack takes a turn, // the weakest first (least HP relative to maximum): each removes 10% of the // remaining population, cuts the tile's production per head by 30% and heals // one hit point per 1000 people it drives off. requestPillage(unitIds) { const units = this._unitsForOrder(unitIds); if (!this.canPillage(unitIds)) return false; const coords = units[0].coords; const owner = this.civAt(coords); const k = key(coords.x, coords.y); const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp); let population = this.tilePopulation.get(k) || 0; const { steps } = pillageSteps(population, ordered.length); let penalty = this.tileProductionPenalty.get(k) !== undefined ? this.tileProductionPenalty.get(k) : 1; let drivenOff = 0; for (let i = 0; i < ordered.length; i++) { penalty *= PILLAGE.productionPenalty; const unit = ordered[i]; unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal); drivenOff += steps[i].removed; } population = steps.length > 0 ? steps[steps.length - 1].remaining : population; this.tilePopulation.set(k, population); this._populationVersion += 1; this.tileProductionPenalty.set(k, penalty); this._gdpPerCapitaCache.delete(k); // The people driven off the tile are civilians lost to its owner in the war // against the pillaging civilisation. if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff); // Pillaging the defenceless is a war crime the world remembers. this._recordWarCrime(units[0].civ, coords, owner); this._emitChanged(); return true; }, // How many levels of `protoIndex` already sit in the city's production queue, // so the next order is priced off the level the waiting ones will reach. _pendingBuildingLevels(cityId, protoIndex) { const queue = this.training.get(cityId); if (!queue) return 0; let count = 0; for (const entry of queue) { if (entry.kind === "building" && entry.protoIndex === protoIndex) count += 1; } return count; }, _unitsForOrder(unitIds) { const ids = Array.isArray(unitIds) ? unitIds : [unitIds]; const units = []; for (const id of ids) { const unit = this.findUnit(Number(id)); if (unit) units.push(unit); } return units; }, };