Added the resource economy, its works and their icons
- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep. - Resource-producing tile works built outside cities; every nation opens with the works its economy needs. - Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
@@ -98,15 +98,154 @@ simplification worth revisiting. Anything unticked is still open.
|
||||
enemy walking on it), coastal cannon (auto-fires on warships).
|
||||
- [x] Embark / disembark orders wired on server and client, with a cargo readout
|
||||
in the unit panel.
|
||||
- [ ] Easy and ergonomic way to split unit stacks.
|
||||
- [ ] Give stacks with range units a dedicated button to perform ranged attacks,
|
||||
right-clicking will now ONLY move (obviously triggers a battle when moving on the same tile)
|
||||
|
||||
## 6. Content and tuning
|
||||
|
||||
- [x] Market and Bank base cost reduced tenfold (500k and 1M).
|
||||
|
||||
## 7. Resources
|
||||
|
||||
- [x] Add the following resource groups:
|
||||
- [x] Energy. Uses the electric icon. In kWh, MWh, GWh, TWh.
|
||||
- [x] Steel. Uses the I-beam icon. In tonnes.
|
||||
- [x] Food. Uses the apple icon. In tonnes.
|
||||
- [x] Luxury. Uses the diamond icon. In carats.
|
||||
- [x] High-tech. Uses the microchip icon. In units.
|
||||
|
||||
* [x] Resources are stored on every tile that is either a city or a resource production building.
|
||||
* [x] Cities and tiles automatically try to buy necessary resources from their neighbours, including non-hostile cities.
|
||||
* [x] Energy produced by power plants cannot be stored.
|
||||
* [x] Keep track of resource prices in the global market.
|
||||
* [x] Transporting resources costs additional money, used to automatically buy energy.
|
||||
* [~] Fossil fuels are abstracted into the transformative plants' money upkeep
|
||||
rather than a stored fuel resource: the roadmap lists only the five
|
||||
groups above, so coal, gas and oil are not separately tracked.
|
||||
* [~] Energy is modelled as a daily flow everywhere, so no city can bank it.
|
||||
Only the other four groups have per-tile storage.
|
||||
|
||||
|
||||
### Delivering resources
|
||||
* [x] Delivering 1 tonne of resources over 1 tile requires 10 MWh by truck over roads, 50 MWh * tile movement cost without roads, 1 MWh by train, 50 kWh by ship.
|
||||
* [x] A delivery graph, revealed only in the economic map mode, is created.
|
||||
* [x] The delivery graph is built
|
||||
* [~] A city always buys from the cheapest source: a nearby store whose
|
||||
delivered price beats the market, or the global market itself. Market
|
||||
buying uses a nominal two-tile road delivery.
|
||||
|
||||
### Food
|
||||
* [x] Each person requires 1 kg of food per day. That's how much cities have to buy. Cities attempt to buy up to 130 % of their needs for the month.
|
||||
* [x] Producing 1 tonne of food requires 1 MWh of energy.
|
||||
|
||||
### Energy
|
||||
* [x] Cities require energy to function. Add a new economic metric (not in the top though), kWh/€ of GDP. This determines the base energy consumption of cities.
|
||||
* [x] Electric energy is delivered instantly to the entire connected region when requested, at no cost.
|
||||
Every country starts having each of their contiguous cities be connected.
|
||||
* [x] Without electric energy, cities and buildings attempt to buy energy that has to be delivered. 10 GWh of energy count as 1 tonne of material.
|
||||
* [x] Energy is needed to create all other resources
|
||||
|
||||
### Steel
|
||||
* [x] Everything requires steel to be built.
|
||||
* [x] 20 MWh/tonne of steel at the steel mill
|
||||
* [x] Each inhabitant consumes 1 kg of steel per day.
|
||||
|
||||
### Luxury
|
||||
* [x] Inhabitants require 0.01 ct of luxury per day. No building or military unit requires that.
|
||||
|
||||
### High-tech
|
||||
* [x] Science buildings require high-tech so they can be built, and they will consume 1 unit of high-tech per tech point they produce.
|
||||
|
||||
### Buildings
|
||||
* [x] New resource-producing buildings, built only _outside_ of cities:
|
||||
* [x] Energy buildings:
|
||||
* [x] Electricity buildings
|
||||
* [x] Transformative plants: turn storable fossil fuels into electricity.
|
||||
* [x] Coal power plant
|
||||
* [x] Natural gas power plant
|
||||
* [x] Oil power plant
|
||||
* [x] Renewable energy plants: create a limited amount of electricity for free
|
||||
* [x] Solar power plant
|
||||
* [x] Geothermal plant
|
||||
* [x] Wind turbines
|
||||
* [x] Offshore wind turbines
|
||||
* [x] Fusion power plant
|
||||
* [x] Steel buildings:
|
||||
* [x] Steel mill
|
||||
* [x] Food buildings:
|
||||
* [x] Food is automatically produced by tiles, in the same proportions as their population.
|
||||
* [x] Luxury buildings:
|
||||
* [x] Gold mine. 10 MWh/ct
|
||||
* [x] Diamond mine. 5 MWh/ct
|
||||
* [x] High-tech building:
|
||||
* [x] Chip foundry.
|
||||
|
||||
* [x] A dedicated resource panel in the nation modal: the Resources tab shows
|
||||
each group's stores, daily need, upkeep and production beside the global
|
||||
market's price and the world's supply and demand.
|
||||
* [x] Every nation opens with the resource works its economy needs, laid out
|
||||
randomly from the world seed at map generation (converters first, then
|
||||
enough power for each connected grid) so the opening economy covers its
|
||||
people's, industry's and upkeep's needs. Works beside the transport
|
||||
network get a one-tile road spur so their output can reach a city, and the
|
||||
per-capita balance is re-derived afterwards so no nation starts richer
|
||||
just because its land hosts more industry. Food is not covered by any
|
||||
building, so a food-poor nation still buys on the market.
|
||||
* [x] A port also lifts its region's food output: 5% per adjacent sea tile per
|
||||
level, on top of its GDP-per-capita bonus.
|
||||
|
||||
### Upkeep and repairs
|
||||
* [x] Buildings, units, tile works and the transport network no longer cost
|
||||
money to keep; they wear out in materials (steel, with high-tech for
|
||||
advanced things) that the state buys from its stores and the market.
|
||||
Money upkeep is now only propaganda campaigns.
|
||||
* [x] Construction spends materials as well as money (steel proportional to the
|
||||
cost, high-tech for advanced items).
|
||||
* [x] Repairs cost materials: a razed city building level is rebuilt once paid
|
||||
for, a damaged unit heals at a steel/high-tech cost, a damaged tile work
|
||||
heals once its steel debt is cleared, and a tile with unpaid damage does
|
||||
not recover its GDP.
|
||||
* [x] Infantry units carry 1,000 soldiers. Training one draws that many people
|
||||
at random out of the region's population, and healing a damaged unit draws
|
||||
replacement soldiers the same way. A unit's soldiers need double rations in
|
||||
combat and triple while wounded and resting in one of their own cities.
|
||||
* [x] Supply applies to every land unit: it carries 50 days of food. At home, or
|
||||
in foreign territory with a clear route (land or maritime) to an allied
|
||||
city, it eats from the nearest such city and tops its pack up; cut off, the
|
||||
days run down and then the soldiers start dying of starvation.
|
||||
* [x] Encirclement is local: a tile is encircled when enemy units hold every
|
||||
land tile beside it and it has no working port. An encircled city or unit
|
||||
loses its neighbours and the foreign market and eats its own reserves. A
|
||||
port keeps its tile supplied unless enemy warships hold every sea tile
|
||||
around the city (a blockade).
|
||||
* [x] Every naval city opens the game with a port. A working port on a city's
|
||||
continent is the only link to other continents: without one, cities trade
|
||||
over land on their own continent only and cannot use the foreign market
|
||||
(nor can construction, upkeep, combat or unit supply draw on it).
|
||||
* [x] Every delivery runs on the transport network: roads and railways on land,
|
||||
shipping lanes across water (which need a working port). There is no
|
||||
off-road trucking, so a resource building or city off the network cannot be
|
||||
bought from until it is linked, and a nation's market is reached along the
|
||||
same roads and rails. Goods never cross the land of a nation you are at war
|
||||
with.
|
||||
* [x] Guns and aircraft are cheap parked and costly to fire: air defense,
|
||||
artillery and aircraft have little standing upkeep and spend munitions
|
||||
(steel/high-tech) on every volley or sortie, with no shot when the
|
||||
materials cannot be found.
|
||||
* [x] The fusion power plant is gated behind Fusion Power, a 500,000-point
|
||||
technology.
|
||||
* [ ] A resource budget line in the national budget breakdown (the money spent
|
||||
on upkeep, electric imports, goods and their transport is tracked by the
|
||||
model but the actual spend is not yet its own row in the budget table; the
|
||||
table currently forecasts upkeep at market prices).
|
||||
|
||||
|
||||
## Open items
|
||||
|
||||
- [ ] A dedicated structure hit-point model for cities and tile improvements
|
||||
(see the `[~]` note above).
|
||||
- [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
|
||||
`icon_launch.svg`, `icon_fortifications.svg`, `icon_radar_tower.svg`,
|
||||
`icon_coastal_cannon.svg`, `icon_road.svg`, `icon_railway.svg`.
|
||||
`icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
|
||||
`icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
|
||||
`icon_wind_turbines.svg`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">chip foundry</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><g><path d="M78.59400177001953 20.312999725341797C58.198001861572266 20.229999542236328 38.55699920654297 24.14299964904785 20.81399917602539 32.78099822998047C126.01599884033203 63.042999267578125 213.2100067138672 117.80000305175781 289.18499755859375 187.81300354003906C279.2070007324219 142.0749969482422 248.77099609375 100.38300323486328 209.80999755859375 70.03299713134766C198.66700744628906 78.38300323486328 182.0850067138672 78.53800201416016 168.6540069580078 70.78299713134766C155.2519989013672 63.042999267578125 147.12399291992188 48.63999938964844 148.7480010986328 34.845001220703125C125.55799865722656 25.607999801635742 101.60299682617188 20.406999588012695 78.59200286865234 20.312999725341797Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M180.218994140625 26.937999725341797C175.00399780273438 27.104000091552734 170.7030029296875 29.413000106811523 168.68699645996094 32.90599822998047C165.2449951171875 38.86800003051758 168.13699340820312 48.88100051879883 178.02999877929688 54.59400177001953C187.9239959716797 60.30699920654297 198.02699279785156 57.805999755859375 201.47000122070312 51.84400177001953C204.91000366210938 45.88199996948242 202.0500030517578 35.8380012512207 192.156005859375 30.124000549316406C188.4459991455078 27.983999252319336 184.6909942626953 27.016000747680664 181.281005859375 26.937999725341797C180.927001953125 26.92799949645996 180.56700134277344 26.926000595092773 180.2209930419922 26.937999725341797Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M136.968994140625 100.84400177001953L31.75 283.18798828125C37.72200012207031 291.6419982910156 44.84299850463867 297.4779968261719 56.875 298.25L162.34500122070312 115.47000122070312C154.31500244140625 110.16000366210938 145.84500122070312 105.29199981689453 136.968994140625 100.84400177001953Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M331.7489929199219 107.09400177001953L314.3429870605469 117.8740005493164L304.12298583984375 184.531005859375L342.4360046386719 125.40699768066406L331.7489929199219 107.09500122070312Z" class="" fill="#50e3c2" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M191.40499877929688 168.81399536132812L169.56100463867188 182.281005859375L183.93600463867188 199.59500122070312L270.0929870605469 194.84500122070312L191.40499877929688 168.81500244140625Z" class="" fill="#50e3c2" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M401.1549987792969 169.5L397.375 170.656005859375L216.27999877929688 226.56199645996094L212.52999877929688 227.75L210.72000122070312 231.25L168.406005859375 314.125L164.781005859375 321.25L171.25100708007812 325.93798828125L387.781005859375 483.18798828125L397.9070129394531 490.531005859375L402.06298828125 478.72100830078125L490.6260070800781 227.281005859375L493.2820129394531 219.781005859375L486.2820129394531 215.9709930419922L404.62701416015625 171.40699768066406L401.1570129394531 169.50100708007812Z" class="selected" fill="#50e3c2" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M399.125 189.68800354003906L462.4049987792969 224.25L396.43499755859375 244.593994140625L363.18701171875 200.781005859375L399.1239929199219 189.68800354003906Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M344.1549987792969 206.656005859375L377.43499755859375 250.468994140625L279 280.8429870605469L281.7799987792969 225.90499877929688L344.156005859375 206.65499877929688Z" class="" fill="#ffffff" stroke="#000000" stroke-opacity="1" stroke-width="5" fill-opacity="1"></path><path d="M262.7799987792969 231.77999877929688L260 286.7200012207031L192.77999877929688 307.4700012207031L225.56199645996094 243.27999877929688L262.7820129394531 231.77999877929688Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M465.2799987792969 242.906005859375L400 428.31201171875L404.06298828125 261.81201171875L465.281005859375 242.906005859375Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M385.2200012207031 267.6260070800781L381.156005859375 434.1260070800781L282.343994140625 299.406005859375L385.218994140625 267.6260070800781Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path><path d="M263.43701171875 305.218994140625L364.75 443.3739929199219L201.06199645996094 324.5L263.43701171875 305.2200012207031Z" class="" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="M376 211H256V16L136 301h120v195z" fill="#f8e71c" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 410 B |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="M255.563 19.75c-13.31 57.822-16.625 100.686-13.907 156.938l18.813.03c3.31-49.616 18.56-88.618 45.874-133-4.86-9.134-13.465-14.956-23.813-18.78-8.908-3.293-18.82-4.77-26.967-5.188zm98.75 94.156c-9.067.133-18.416 1.204-28 3.313-13.76 3.026-27.176 7.952-39.97 14.56-3.905 14.615-6.346 29.844-7.406 46.595 1.25-.216 2.51-.432 3.75-.688 24.965-5.135 43.288-17.244 52.344-32.25-.298 19.943-25.12 41.175-56.81 49.97-2.525.7-5.086 1.34-7.69 1.874-44.29 9.114-85.27-8.315-99.842-35.343-4.366-8.097-5.176-16.436-3.094-24.343 9.834 17.98 30.633 31.534 55.25 38.156-.748-16.246-.974-31.515-.625-46.406-11.004-4.878-22.38-8.548-33.97-10.875-11.18-2.246-22.044-3.414-32.094-3.064-71.757 2.496-132.96 64.885-131.78 154.688C25.73 373.264 101.85 472.424 190 490.124c11.178 2.246 24.934 3.42 37.22 2.282 12.283-1.138 22.775-4.762 28.405-10.156l5.97-5.72 6.405 5.25c5.378 4.426 16.542 8.05 29.125 9.22 12.583 1.17 26.514.195 37.344-2.188 87.533-19.257 153.047-119.08 151.686-222.718-.677-51.615-19.354-93.606-48.22-120.22-21.647-19.96-48.954-31.508-79.748-31.968-1.284-.02-2.58-.02-3.875 0zm49.906 41.125c53.69 32.478 75.357 115.23 37.092 200.626-30.48 68.017-93.18 121.256-140.843 118.75C411.304 407.96 458.192 271.063 404.22 155.03z" fill="#b8e986" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="m13.447 18 4.826 20.656c17.47 32.683 68.819 20.747 94.545-1.635-14.136 38.399-65.72 49.25-95.964 26.354-.794 11.045 1.01 18.688 4.326 23.547 4.729 6.93 13.658 10.949 30.021 11.203l12.475.193-4.12 11.778c-5.347 15.288-4.928 23.375-1.91 28.478 1.975 3.337 6.034 6.533 12.678 9.545 40.193-8.27 83.572-9.067 121.676-9.119 28.214 0 56.41 1.001 79.78 3.033 23.37 2.032 41.025 4.306 52.245 9.916 10.816 5.781 25.574 5.525 36.207 3.994 9.222-1.415 16.325-4.613 21.387-10.853 3.954-4.874 7.015-12.152 7.9-23.31-33.606 4.231-67.935-8.9-76.294-47.872 40.54 37.963 82.92 42.849 131.005 16.412 11.444-6.49 18.25-14.805 20.485-27.605l1.392-7.975 8.079.543c7.79.524 14.336.366 19.814-.267V18h-21.979c-20.534 43.036-74.988 67.68-115.3 21.922 29.23 13.223 71.841 14.87 94.744-21.922H13.447zm270.645 29.957c-7.744 21.146-22.92 44.73-47.414 51.688-26.23 34.754-92.726 38.651-119.86-17.305 20.443 24.144 61.662 34.958 91.328 17.668-27.204-8.009-48.057-46.875-46.736-47.535 0 2.077 41.737 29.713 57.897 29.654 25.989.029 43.257-14.207 64.785-34.17zM494 72.945c-4.196.368-8.676.507-13.467.412-4.623 14.341-14.644 25.372-27.422 32.62-12.914 7.325-28.605 11.249-45.64 13.244-.675 15.828-4.645 28.298-11.873 37.209-8.483 10.457-20.558 15.453-32.637 17.306-12.578 1.93-25.722.924-37.504-.76a236.7 236.7 0 0 0-4.86 21.258C338.19 192.74 359.193 192 380.23 192c21.401 0 42.782.76 60.555 2.309 16.293 1.419 28.797 2.843 37.914 6.648 5.832-1.888 11.047-3.878 15.301-6.03v-22.613c-24.68 10.243-52.42 3.068-67.861-28.8 16.333 12.485 44.326 23.76 67.861 8.746V72.945zM192 157c-40.75 1.88-77.826-.767-116.965 8.832 28.05 96.266-1.687 216.838-32.195 309.791 11.247 5.175 30.635 10.198 53.91 13.465C124.683 493.008 158.353 495 192 495s67.317-1.992 95.25-5.912c23.275-3.267 42.663-8.29 53.91-13.465-30.508-92.953-60.246-213.525-32.195-309.791-8.367-2.095-22.342-4.439-38.744-5.865C247.59 157.999 219.786 157 192 157zm188.23 53c-20.97 0-41.962.757-58.994 2.24-1.125.098-2.22.207-3.308.317-8.896 79.06 12.998 172.267 37.445 248.76a632.12 632.12 0 0 0 24.857.501c25.373 0 50.765-1.507 71.762-4.459 16.35-2.298 30.045-5.83 38.403-9.298-22.686-69.604-44.617-159.096-24.422-231.875-6.263-1.396-15.673-2.981-26.748-3.946-17.032-1.483-38.023-2.24-58.995-2.24zm-141.335 28.21c29.289 17.126 45.21 48.33 44.464 80.056l-61.252-6c-.43-7.908-4.625-15.474-11.837-19.84l28.625-54.215zm-92.956 1.12 27.79 52.514a24.504 24.504 0 0 0-9.616 9.347 24.489 24.489 0 0 0-3.258 11.075l-57.384 6c-.42-16.048 3.166-32.448 11.78-47.37 7.723-13.372 18.408-23.987 30.688-31.566zm45.895 64.092c3.515.034 5.522.778 7.762 2.07a16.161 16.161 0 0 1 5.933 22.162 16.144 16.144 0 0 1-22.146 5.934 16.165 16.165 0 0 1-5.947-22.162c2.815-4.876 8.244-7.778 12.794-7.975a30.402 30.402 0 0 1 1.604-.03zm12.478 43.203 31.467 48.578c-13.778 7.362-29.255 11.298-44.92 10.89-14.368-.372-28.682-4.176-42.029-11.782l30.828-47.12c7.95 4.349 17.346 3.852 24.655-.566z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">geothermal plant</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="m169.5 38.97-2.406 9-4.313 16.093-34.936-9.344-3.375 12.53-26.75-7.156-4.845 18.03 26.78 7.19-2.874 10.655 34.94 9.343-12.25 45.718-34.94-9.343-3.31 12.282-26.75-7.157-4.845 18.062 26.75 7.156-2.906 10.907 34.936 9.344-12.53 46.69-34.907-9.345L78 240.75l-26.78-7.188-4.845 18.032 26.78 7.187-3.25 12.095 34.907 9.344-12 44.75-34.906-9.345-2.875 10.75-26.78-7.188-4.813 18.032 26.75 7.155-3.343 12.53 34.906 9.345-4.47 16.688-2.405 9.03 9.03 2.407 254.845 68.28 9.03 2.408 2.408-9.032 4.468-16.655 34.813 9.313 3.342-12.5 27.032 7.25 4.844-18.032-27.032-7.25 2.875-10.75-34.81-9.312 12-44.75 34.81 9.312 3.25-12.094 27.033 7.25 4.812-18.03-27.03-7.25 3-11.157-34.814-9.313 12.5-46.656 34.814 9.313L439 263.843l27.03 7.25 4.845-18.063-27.03-7.25 3.28-12.31-34.78-9.314 12.25-45.72 34.78 9.314 2.844-10.625 27.06 7.25 4.845-18.063-27.063-7.25 3.375-12.593-34.78-9.314 4.312-16.062 2.436-9.03-9.03-2.44-254.845-68.25-9.03-2.405zm13.188 22.874L419.53 125.28l-84.966 316.91L97.75 378.75l84.938-316.906zM375.28 132.78 305.5 393.22l-175.313-47-4.843 18.06 184.376 49.41 9 2.406 2.436-9.03 72.188-269.44-18.063-4.843z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="m92.906 94.813 60.438 79.75 78.125-79.75H92.905zm189.25 0L359.25 173.5l58.688-78.688H282.155zm-25.344.843-84.718 86.47H341.53l-84.717-86.47zm177.907 7.906-58.626 78.563H494.53l-59.81-78.563zm-358.064.75-57.78 77.813h116.78l-59-77.813zm-58.5 96.5L226.562 429.22l-83.218-228.41H18.156zm145.063 0 93.593 256.844 93.593-256.844H163.22zm207.06 0L287.064 429.22l208.405-228.41H370.28z" fill="#50e3c2" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">offshore wind</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M219.615 401.84h16v88.06h-16v-88.06zm219.94-271.6 21.83-13.18-21.82-13.16 16-19.8-25-4.88 8.25-24.12-25.19 4-.46-25.49-22.28 12.29-9.18-23.8-16.7 19.24-16.78-19.24-9.22 23.8-22.3-12.31-.46 25.49-25.19-4 8.25 24.12-25 4.88 16 19.8-21.83 13.18 21.83 13.14h33.41l29.29 76.34 12-13.76 16.74 19.24 9.17-23.76 22.3 12.31.46-25.49 25.19 4-8.28-24.18 25-4.88zm-254.55 46.31h-91l31 80.85h91zm108.25 0h-91l30.94 80.85h91zm-70.81 97.42h-91l30.94 80.85h91zm108.25 0h-91l30.94 80.85h91zm73.89 111.87h-262.22L50.615 146.2h262.1zm-19.69-15-43.31-112.87-37.28-97.42H70.785l80.47 210.27h233.68z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M2 2h508v508H2z" fill="#000000" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="2"></path><g class="" style="" transform="translate(0,0)"><path d="M39.557 19 323.44 273h149.003L188.56 19H39.558zM25 30.13v25.847L311 311.87v-25.846L25 30.13zm64 107.263v34.584L375 427.87v-84.843l-64-13.002L89 137.393zM77.555 185.89l-42.9 10.723 287.79 257.498 42.9-10.723L77.556 185.89zM25 212.13v23.847L311 491.87v-23.847L25 212.13zM329 291v21.973l64 16v126.054l-64 16V493h158v-21.973l-64-16V328.973l64-16V291H329zm112 135.865v14.108l21.88 5.47L441 426.865z" fill="#7c6a5b" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 771 B |
@@ -0,0 +1 @@
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M41 19v228h25.5c6.96 61.3 40.5 114.1 88.4 145.4l12.1 61.3-95.35-46.4a9.798 9.145 26.09 0 0-4.66-10.5 9.798 9.145 26.09 0 0-13.18 2.9 9.798 9.145 26.09 0 0 3.67 12.8 9.798 9.145 26.09 0 0 10.55-.1L144.5 496h216.6l33.4-22.3a8.636 11.2 54.07 0 0 .3 1.9 8.636 11.2 54.07 0 0 12.8 3.6 8.636 11.2 54.07 0 0 7.2-13 8.636 11.2 54.07 0 0-3.3-3.9l36.5-24.5-89.6 26.2 32.4-57.5-76.5 43.6 6.2-39.1c66.9-25.1 116.3-88.1 125-164H471V19h-30v155.4C420.1 88.03 345.3 23.87 256 23.87S91.93 87.95 71 174.2V19H41zm215 20.54c96.6 0 175.2 81.96 175.2 183.76 0 76.6-44.5 142-107.8 169.7l4.5-28.1L272 464c.1-47.1 1.5-159.3 34.6-204.7 40.7-16.6 68.4-52.4 68.4-93.3 0-56.3-52.6-103-119-103s-119 46.7-119 103c0 40.9 27.7 76.7 68.4 93.3 33.1 45.4 34.5 157.6 34.6 204.7l-51.8-128 .7 52.9-52.5-90 14.1 71.2c-42.3-33.6-69.66-86.7-69.66-146.8C80.84 121.5 159.4 39.54 256 39.54zM170.7 306.3a11.2 8.634 63.66 0 0-.9.1 11.2 8.634 63.66 0 0-5.7 11.9 11.2 8.634 63.66 0 0 11.6 9.4 11.2 8.634 63.66 0 0 5.8-12 11.2 8.634 63.66 0 0-10.8-9.4zm181 30.9a8.998 12.75 27.2 0 0-10.5 7.9 8.998 12.75 27.2 0 0 2.2 15.5 8.998 12.75 27.2 0 0 13.8-7.3 8.998 12.75 27.2 0 0-2.2-15.4 8.998 12.75 27.2 0 0-3.3-.7zM34.96 348.5a11.06 5.999 47.86 0 0-3.04 1 11.06 5.999 47.86 0 0 2.97 12.2 11.06 5.999 47.86 0 0 11.87 4.2 11.06 5.999 47.86 0 0-2.97-12.3 11.06 5.999 47.86 0 0-8.83-5.1zm363.14 26.3a6.561 14.62 40.07 0 0-12 7.8 6.561 14.62 40.07 0 0-4.4 15.4 6.561 14.62 40.07 0 0 14.5-7 6.561 14.62 40.07 0 0 4.4-15.4 6.561 14.62 40.07 0 0-2.5-.8zm81.2 59.7a7.525 16.82 50.66 0 0-15.5 7.8 7.525 16.82 50.66 0 0-5.7 15.7 7.525 16.82 50.66 0 0 18.7-7.1 7.525 16.82 50.66 0 0 5.7-15.7 7.525 16.82 50.66 0 0-3.2-.7zm-404.79 18a13.68 8.998 27.81 0 0-7.87 3.8 13.68 8.998 27.81 0 0 7.9 14.3 13.68 8.998 27.81 0 0 16.3-1.5 13.68 8.998 27.81 0 0-7.9-14.4 13.68 8.998 27.81 0 0-8.43-2.2z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">wind turbines</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
@@ -357,6 +357,24 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* A barrage the player is looking at stands out from the rest. */
|
||||
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
|
||||
|
||||
/* The delivery graph, shown only in the economic map modes: a coloured line
|
||||
per delivery leg and a dot on every storage node. */
|
||||
#layer-resources { pointer-events: none; }
|
||||
.resource-node {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-left: -5px;
|
||||
margin-top: -5px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
background: #2b3550;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.resource-icon { vertical-align: middle; }
|
||||
|
||||
/* Outlines the selected or inspected tile. The polygon is built in JS from the
|
||||
same warped hexagon as the terrain, so it follows the geography. */
|
||||
.tile-selection {
|
||||
@@ -746,13 +764,21 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
/* One button per buildable tile improvement, laid out in a wrapping row. */
|
||||
/* Tile improvements, grouped under a category heading. */
|
||||
.tile-improvement-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.improvement-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.improvement-category {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.improvement-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.improvement-btn { font-size: 12px; }
|
||||
.improvement-btn.current { opacity: 0.6; }
|
||||
.improvement-btn.remove { border-color: rgba(242, 114, 114, 0.6); }
|
||||
@@ -766,7 +792,32 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
padding: 5px 4px;
|
||||
}
|
||||
.improvement-icon { width: 40px; height: 40px; display: block; }
|
||||
.improvement-cost { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.improvement-cost {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0 3px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cost-res { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.cost-icon { width: 12px; height: 12px; vertical-align: middle; }
|
||||
.cost-money { color: var(--muted); }
|
||||
|
||||
/* The tile panel leads with what the tile actually produces. */
|
||||
#tile-production { margin: 4px 0 6px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tile-prod-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tile-prod-row .tile-prod-name { color: var(--muted); }
|
||||
.tile-prod-row .tile-prod-value { margin-left: auto; }
|
||||
.tile-prod-empty { font-size: 12px; color: var(--muted); }
|
||||
|
||||
.info-section.hidden { display: none !important; }
|
||||
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
|
||||
@@ -1228,6 +1279,16 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.conflict-detail .conflict-line { font-size: 12px; color: var(--muted); }
|
||||
.conflict-detail .conflict-side { font-size: 13px; margin-top: 4px; }
|
||||
|
||||
.economy-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||||
.economy-table th,
|
||||
.economy-table td { padding: 5px 8px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.economy-table th { color: var(--muted); font-weight: 600; font-size: 11px; }
|
||||
.economy-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
.central-bank-rate label { color: var(--muted); }
|
||||
.central-bank-rate input { width: 90px; }
|
||||
.subhead { margin-top: 10px; color: var(--muted); font-weight: 600; }
|
||||
|
||||
.diplomacy-table { width: 100%; border-collapse: collapse; }
|
||||
.diplomacy-table th,
|
||||
.diplomacy-table td {
|
||||
@@ -1294,6 +1355,24 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
font-weight: 700;
|
||||
}
|
||||
.budget-table tfoot .budget-total .budget-value { color: #fff; }
|
||||
.budget-table tr.budget-subsection td {
|
||||
padding-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.budget-months {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 18px 0;
|
||||
}
|
||||
.budget-months:empty { display: none; }
|
||||
.budget-month-nav {
|
||||
min-width: 28px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.budget-month-label { color: var(--muted); font-size: 12px; min-width: 120px; text-align: center; }
|
||||
|
||||
.training-status { padding: 0 18px 16px; }
|
||||
.training-queue { padding-top: 0; }
|
||||
@@ -1328,6 +1407,10 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
#city-train-list .train-name { font-weight: 600; }
|
||||
#city-train-list .train-resources { display: inline-flex; align-items: center; gap: 8px; }
|
||||
#city-train-list .train-price { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
#city-train-list .train-time { color: var(--muted); font-size: 12px; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
/* Local test game: the free, instant twin of an order button. */
|
||||
.free-button {
|
||||
border: 1px dashed rgba(130, 225, 170, 0.75) !important;
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
<div class="layer" id="layer-highlight"></div>
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
<div class="layer" id="layer-targets"></div>
|
||||
<div class="layer" id="layer-resources"></div>
|
||||
<div class="layer" id="layer-entities"></div>
|
||||
<div class="layer city-label-layer" id="layer-labels"></div>
|
||||
</div>
|
||||
@@ -118,9 +119,9 @@
|
||||
<div class="top-stats">
|
||||
<div class="stat"><span class="stat-label">Date</span><span id="stat-date" class="stat-value"></span></div>
|
||||
<div class="stat"><span class="stat-label">Population</span><span id="stat-pop" class="stat-value">0</span></div>
|
||||
<div class="stat"><span class="stat-label">GDP</span><span id="stat-gdp" class="stat-value">€0</span></div>
|
||||
<div class="stat"><span class="stat-label">GDP/capita</span><span id="stat-gdppc" class="stat-value">€0</span></div>
|
||||
<div class="stat"><span class="stat-label">Budget</span><span id="stat-budget" class="stat-value">€0</span></div>
|
||||
<div class="stat"><span class="stat-label">GDP</span><span id="stat-gdp" class="stat-value">¤0</span></div>
|
||||
<div class="stat"><span class="stat-label">GDP/capita</span><span id="stat-gdppc" class="stat-value">¤0</span></div>
|
||||
<div class="stat"><span class="stat-label">Budget</span><span id="stat-budget" class="stat-value">¤0</span></div>
|
||||
<div class="stat"><span class="stat-label">Popularity</span><span id="stat-approval" class="stat-value approval-neutral">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +142,7 @@
|
||||
<div id="tile-info" class="info-section hidden">
|
||||
<div id="tile-desc" class="info-title"></div>
|
||||
<div id="tile-coords" class="tile-coords"></div>
|
||||
<div id="tile-production"></div>
|
||||
<div id="tile-ethnicity"></div>
|
||||
<div id="tile-gdp"></div>
|
||||
<div id="tile-improvements"></div>
|
||||
@@ -224,6 +226,7 @@
|
||||
<button class="tab" data-tab="buildings">Buildings</button>
|
||||
<button class="tab" data-tab="people">People</button>
|
||||
<button class="tab" data-tab="budget">Budget</button>
|
||||
<button class="tab" data-tab="economy">Economy</button>
|
||||
</div>
|
||||
<div class="tab-page active" id="city-tab-train">
|
||||
<div id="city-train-list" class="modal-body"></div>
|
||||
@@ -245,6 +248,10 @@
|
||||
<div id="city-budget-sub" class="modal-sub"></div>
|
||||
<div id="city-budget-list" class="modal-body"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="city-tab-economy">
|
||||
<div id="city-economy-sub" class="modal-sub"></div>
|
||||
<div id="city-economy-list" class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,6 +314,8 @@
|
||||
<button class="tab" data-tab="cities">Cities</button>
|
||||
<button class="tab" data-tab="conflicts">Ongoing conflicts</button>
|
||||
<button class="tab" data-tab="budget">Budget</button>
|
||||
<button class="tab" data-tab="economy">Economy</button>
|
||||
<button class="tab" data-tab="resources">Resources</button>
|
||||
</div>
|
||||
<div class="tab-page active" id="tab-government">
|
||||
<div id="government-sub" class="modal-sub"></div>
|
||||
@@ -335,8 +344,22 @@
|
||||
</div>
|
||||
<div class="tab-page" id="tab-budget">
|
||||
<div id="budget-sub" class="modal-sub"></div>
|
||||
<div id="budget-months" class="budget-months"></div>
|
||||
<div id="budget-list" class="modal-body"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-economy">
|
||||
<div id="economy-sub" class="modal-sub"></div>
|
||||
<div id="economy-subtabs" class="subtabs"></div>
|
||||
<div id="economy-summary" class="modal-body"></div>
|
||||
<div id="economy-central-bank" class="modal-body hidden"></div>
|
||||
<div id="economy-rates" class="modal-body hidden"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-resources">
|
||||
<div id="resources-sub" class="modal-sub"></div>
|
||||
<div id="resources-subtabs" class="subtabs"></div>
|
||||
<div id="resources-list" class="modal-body"></div>
|
||||
<div id="resource-trade-list" class="modal-body hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,20 +7,23 @@ export const CREDIT_SECTIONS = [
|
||||
{
|
||||
title: "Visual artists",
|
||||
note:
|
||||
"Unit, building and nation icons drawn for game-icons.net by the artists " +
|
||||
"below, used under CC BY 3.0.",
|
||||
"Unit, building and resource icons drawn for game-icons.net by the " +
|
||||
"artists below, used under CC BY 3.0.",
|
||||
entries: [
|
||||
{
|
||||
name: "Delapouite",
|
||||
detail:
|
||||
"bank, caesar, clapperboard, control tower, delivery drone, " +
|
||||
"encirclement, graduate cap, habitat dome, jet fighter, military " +
|
||||
"fort, modern city, old wagon, rail road, road, ship bow, shop, " +
|
||||
"stone bust, throne",
|
||||
"encirclement, gold mine, graduate cap, habitat dome, i-beam, jet " +
|
||||
"fighter, melting metal, military fort, modern city, nuclear plant, " +
|
||||
"old wagon, power plant, rail road, road, ship bow, shop, stone " +
|
||||
"bust, throne",
|
||||
},
|
||||
{
|
||||
name: "Lorc",
|
||||
detail: "anchor, crossed sabres, radar dish, rocket",
|
||||
detail:
|
||||
"anchor, crossed sabres, cut diamond, diamond hard, microchip, " +
|
||||
"radar dish, rocket, shiny apple",
|
||||
},
|
||||
{
|
||||
name: "Lord Berandas",
|
||||
@@ -29,7 +32,8 @@ export const CREDIT_SECTIONS = [
|
||||
{ name: "Cathelineau", detail: "battleship" },
|
||||
{ name: "Pierre Leducq", detail: "anti-aircraft gun" },
|
||||
{ name: "Quoting", detail: "field gun" },
|
||||
{ name: "Skoll", detail: "FAMAS" },
|
||||
{ name: "sbed", detail: "electric" },
|
||||
{ name: "Skoll", detail: "FAMAS, solar power" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// The viewer's currency. Money is quoted in whatever nation the player runs, so
|
||||
// every money string reads in the right unit. The symbol is set from the
|
||||
// snapshot; until then a neutral mark stands in.
|
||||
|
||||
import { groupDigits } from "../../shared/text_format.js";
|
||||
|
||||
let symbol = "¤";
|
||||
|
||||
export function setCurrency(currency) {
|
||||
if (currency && currency.symbol) symbol = currency.symbol;
|
||||
}
|
||||
|
||||
export function currencySymbol() {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
// A signed money amount, with an optional "/h" for hourly figures.
|
||||
export function moneyText(value, { signed = true, perHour = false } = {}) {
|
||||
const amount = `${symbol}${groupDigits(Math.round(Math.abs(value)))}`;
|
||||
const sign = signed ? (value < 0 ? "−" : "+") : "";
|
||||
return `${sign}${amount}${perHour ? "/h" : ""}`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { panelMethods } from "./game_screen/panels.js";
|
||||
import { CompletionWatcher } from "./game_screen/completions.js";
|
||||
import { BombardmentSfx, AirStrikeSfx, zoomVolume } from "./game_screen/bombing.js";
|
||||
import { audio } from "./audio.js";
|
||||
import { setCurrency } from "./currency.js";
|
||||
|
||||
export class GameScreen {
|
||||
constructor(network, config) {
|
||||
@@ -37,15 +38,16 @@ export class GameScreen {
|
||||
this._trainingCityId = 0;
|
||||
this._cityBuildings = new Map();
|
||||
this._population = new Map();
|
||||
this._gdpBaseline = new Map();
|
||||
this._productionBaseline = new Map();
|
||||
this._regionProductionBase = new Map();
|
||||
this._tileEthnicity = new Map();
|
||||
this._populationVersion = undefined;
|
||||
this._gdpBaselineVersion = undefined;
|
||||
this._productionBaselineVersion = undefined;
|
||||
this._ethnicityVersion = undefined;
|
||||
this._territoryVersion = undefined;
|
||||
this._regionVersion = undefined;
|
||||
this._tileGdpPenalties = new Map();
|
||||
this._tileBattleGdpDeficits = new Map();
|
||||
this._tileProductionPenalties = new Map();
|
||||
this._tileBattleProductionDeficits = new Map();
|
||||
this._territoryOwner = new Map();
|
||||
this._tileRegion = new Map();
|
||||
this._gdpExpanded = false;
|
||||
@@ -92,6 +94,7 @@ export class GameScreen {
|
||||
highlight: $("#layer-highlight"),
|
||||
paths: $("#layer-paths"),
|
||||
targets: $("#layer-targets"),
|
||||
resources: $("#layer-resources"),
|
||||
entities: $("#layer-entities"),
|
||||
labels: $("#layer-labels"),
|
||||
}
|
||||
@@ -159,6 +162,7 @@ export class GameScreen {
|
||||
onState(state) {
|
||||
this.snapshot = state;
|
||||
this.localCiv = state.viewer;
|
||||
setCurrency(state.viewerStats && state.viewerStats.currency);
|
||||
this.testing = !!(state.testing || (this.config && this.config.testing));
|
||||
this.civilisations = state.civs.map(
|
||||
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
|
||||
@@ -176,14 +180,17 @@ export class GameScreen {
|
||||
this._populationVersion = versionOf("population");
|
||||
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
}
|
||||
// The opening GDP per capita of every land tile never changes, so it is
|
||||
// built once and kept across the deltas that omit it.
|
||||
if (versionChanged("gdpBaseline", this._gdpBaselineVersion)) {
|
||||
this._gdpBaselineVersion = versionOf("gdpBaseline");
|
||||
this._gdpBaseline = new Map(
|
||||
(state.gdpBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
// The opening production per head of every land tile never changes, so it
|
||||
// is built once and kept across the deltas that omit it.
|
||||
if (versionChanged("productionBaseline", this._productionBaselineVersion)) {
|
||||
this._productionBaselineVersion = versionOf("productionBaseline");
|
||||
this._productionBaseline = new Map(
|
||||
(state.productionBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
}
|
||||
// Each region's production per head, before tile modifiers. It moves with
|
||||
// production and prices, so it is rebuilt from every snapshot.
|
||||
this._regionProductionBase = new Map((state.regionProductionBase || []).map((e) => [e[0], e[1]]));
|
||||
if (versionChanged("ethnicity", this._ethnicityVersion)) {
|
||||
this._ethnicityVersion = versionOf("ethnicity");
|
||||
this._tileEthnicity = new Map(
|
||||
@@ -198,11 +205,11 @@ export class GameScreen {
|
||||
this._regionVersion = versionOf("regions");
|
||||
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
||||
}
|
||||
this._tileGdpPenalties = new Map(
|
||||
(state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
this._tileProductionPenalties = new Map(
|
||||
(state.tileProductionPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
this._tileBattleGdpDeficits = new Map(
|
||||
(state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
this._tileBattleProductionDeficits = new Map(
|
||||
(state.tileBattleProductionDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
||||
);
|
||||
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
|
||||
this._cityBuildings = new Map();
|
||||
@@ -227,6 +234,9 @@ export class GameScreen {
|
||||
// Mirror the viewer's repeatable-research bonuses used by prediction.
|
||||
this.map.railSpeed = (state.viewerStats || {}).railSpeed || 0;
|
||||
this.map.applySnapshot(state);
|
||||
// The delivery graph is delta-dropped while unchanged, so only replace the
|
||||
// view's copy when the snapshot actually carried one.
|
||||
if (state.resourceGraph) this.map.setResourceGraph(state.resourceGraph);
|
||||
// Refresh an active economic map mode against the new figures.
|
||||
this._refreshEconomicMode();
|
||||
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// on the GameScreen instance (composed in ../game_screen.js) and only read the
|
||||
// snapshot and forward orders.
|
||||
|
||||
import { describeNews } from "../modals.js";
|
||||
import { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js";
|
||||
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
|
||||
import {
|
||||
CIVILISATIONS,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
NEWS_TECHNOLOGY,
|
||||
NEWS_JOIN,
|
||||
ECONOMY,
|
||||
EFFECT_GDP,
|
||||
EFFECT_PRODUCTION,
|
||||
APPROVAL,
|
||||
effectValue,
|
||||
pillageSteps,
|
||||
@@ -23,29 +23,22 @@ import {
|
||||
TRANSPORT_IMPROVEMENTS,
|
||||
transportImprovement,
|
||||
TILE_IMPROVEMENTS,
|
||||
RESOURCE_BUILDINGS,
|
||||
resourceById,
|
||||
RESOURCES,
|
||||
RESOURCE_RULES,
|
||||
tileImprovementById,
|
||||
AIR_STRIKE,
|
||||
} from "../../../shared/data.js";
|
||||
import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js";
|
||||
import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
|
||||
import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
|
||||
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js";
|
||||
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
|
||||
import { parseKey } from "../../../shared/hex.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { isEconomicMode } from "../map_view/economic.js";
|
||||
import { isWaterTile } from "../map_view/textures.js";
|
||||
import { audio } from "../audio.js";
|
||||
|
||||
// A tiny memo cache keyed by bucket and key, used while a per-tile economic
|
||||
// figure is computed for every tile at once.
|
||||
function memo(caches, bucket, key, compute) {
|
||||
if (!caches) return compute();
|
||||
let cache = caches[bucket];
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
caches[bucket] = cache;
|
||||
}
|
||||
if (!cache.has(key)) cache.set(key, compute());
|
||||
return cache.get(key);
|
||||
}
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
export const panelMethods = {
|
||||
_bindHud() {
|
||||
@@ -98,6 +91,8 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "policy", policy, ethnicity });
|
||||
this.nationModal.onRevokePolicy = (policy) =>
|
||||
this.sendOrder({ type: "revoke_policy", policy });
|
||||
this.nationModal.onSetInterestRate = (rate) =>
|
||||
this.sendOrder({ type: "set_interest_rate", rate });
|
||||
|
||||
$("#unit-attack")
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
@@ -286,12 +281,12 @@ export const panelMethods = {
|
||||
// A unit in combat cannot pillage.
|
||||
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
|
||||
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
|
||||
const { steps, gdpMultiplier } = pillageSteps(population, units.length);
|
||||
const { steps, productionMultiplier } = pillageSteps(population, units.length);
|
||||
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
|
||||
const healed = ordered.map((unit, index) =>
|
||||
Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp
|
||||
);
|
||||
return { population, steps, gdpMultiplier, healed };
|
||||
return { population, steps, productionMultiplier, healed };
|
||||
},
|
||||
|
||||
_updatePillageButton(units) {
|
||||
@@ -308,8 +303,8 @@ export const panelMethods = {
|
||||
.removeClass("hidden")
|
||||
.attr(
|
||||
"data-tooltip",
|
||||
`Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
|
||||
`GDP/capita ×${forecast.gdpMultiplier.toFixed(2)} · heals ${heals} HP`
|
||||
`Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
|
||||
`production ×${forecast.productionMultiplier.toFixed(2)} · heals ${heals} HP`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -408,9 +403,10 @@ export const panelMethods = {
|
||||
}
|
||||
const cost = (AIR_STRIKE.cost || 0) * units.length;
|
||||
if (cost > budget) {
|
||||
const prices = marketPricesFrom(this.snapshot);
|
||||
return {
|
||||
title: "Insufficient budget",
|
||||
message: `An air strike costs €${groupDigits(cost)}, which the treasury cannot cover.`,
|
||||
message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -517,9 +513,9 @@ export const panelMethods = {
|
||||
// stat can show whether it rose or fell since.
|
||||
const month = ((state.monthly || [])[this.localCiv]) || null;
|
||||
$("#stat-pop").text(compact(stats.population || 0));
|
||||
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
|
||||
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
|
||||
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
|
||||
$("#stat-gdp").text(`${currencySymbol()}${compact(stats.gdp || 0)}`);
|
||||
$("#stat-gdppc").text(`${currencySymbol()}${compact(stats.gdpPerCapita || 0)}`);
|
||||
$("#stat-budget").text(`${currencySymbol()}${compact(stats.budget || 0)}`);
|
||||
// GDP, population and GDP/capita double the arrow past a 10% month-on-month
|
||||
// move; the others only ever show one.
|
||||
this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
|
||||
@@ -621,7 +617,7 @@ export const panelMethods = {
|
||||
}
|
||||
const expected = net * hoursInNextMonth(totalHours || 0);
|
||||
const sign = expected < 0 ? "−" : "+";
|
||||
this._setStatTooltip("stat-budget", `${sign}€${compact(Math.abs(expected))}/mo`);
|
||||
this._setStatTooltip("stat-budget", `${sign}${currencySymbol()}${compact(Math.abs(expected))}/mo`);
|
||||
},
|
||||
|
||||
// The summary's popularity figure: white while it sits in the neutral band
|
||||
@@ -920,7 +916,10 @@ export const panelMethods = {
|
||||
$("#tile-desc").text(this._tileDescription(coords, owner, tile, regionCity));
|
||||
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
|
||||
this._renderTilePeople(k);
|
||||
this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
|
||||
this._renderTileProduction(
|
||||
this._tileResourceProduction(coords, owner, city, tile)
|
||||
);
|
||||
this._renderRegionGdp(owner >= 0 ? regionCity : null);
|
||||
const transport = this.map.railways.has(k)
|
||||
? ["Railway"]
|
||||
: this.map.roads.has(k)
|
||||
@@ -934,7 +933,7 @@ export const panelMethods = {
|
||||
if (this.map.trenches.has(k)) works.push("Trench");
|
||||
const militaryWorks = this.map.tileImprovements.get(k);
|
||||
if (militaryWorks) {
|
||||
const proto = TILE_IMPROVEMENTS.find((entry) => entry.id === militaryWorks.id);
|
||||
const proto = tileImprovementById(militaryWorks.id);
|
||||
works.push(proto ? proto.name : militaryWorks.id);
|
||||
}
|
||||
$("#tile-improvements").text(
|
||||
@@ -968,7 +967,7 @@ export const panelMethods = {
|
||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||
);
|
||||
if (!work) return;
|
||||
const proto = TILE_IMPROVEMENTS.find((improvement) => improvement.id === work.id);
|
||||
const proto = tileImprovementById(work.id);
|
||||
const name = proto ? proto.name : work.id;
|
||||
const total = Math.max(work.totalHours || 0, 0.0001);
|
||||
const elapsed = work.elapsedHours || 0;
|
||||
@@ -1011,7 +1010,8 @@ export const panelMethods = {
|
||||
|
||||
// A square icon button for one buildable improvement: the type's icon with
|
||||
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
|
||||
_improvementButton(improvement, { className, costLabel, title, disabled, current }) {
|
||||
// A `costLabel` override replaces the resource cost (used by the free button).
|
||||
_improvementButton(improvement, { className, costLabel, title, disabled, current, prices } = {}) {
|
||||
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
|
||||
const $button = $(`<button type='button' class='${classes}'></button>`)
|
||||
.attr("title", title)
|
||||
@@ -1020,15 +1020,83 @@ export const panelMethods = {
|
||||
$button.append(
|
||||
$("<img class='improvement-icon'>")
|
||||
.attr("src", `assets/${improvement.icon}`)
|
||||
.attr("alt", improvement.name),
|
||||
$("<span class='improvement-cost'></span>").text(costLabel)
|
||||
.attr("alt", improvement.name)
|
||||
);
|
||||
if (costLabel !== undefined) {
|
||||
$button.append($("<span class='improvement-cost'></span>").text(costLabel));
|
||||
} else {
|
||||
$button.append(this._improvementCost(improvement, prices));
|
||||
}
|
||||
return $button;
|
||||
},
|
||||
|
||||
// One square icon button per transport and military improvement, plus a
|
||||
// control to remove the one already standing on the tile. Shown only on a
|
||||
// controlled land tile; an unaffordable improvement is greyed out.
|
||||
// The cost line under an improvement icon: the steel, electricity and
|
||||
// high-tech it spends, each with its resource icon, then the full price at
|
||||
// today's market (build cost plus the material bill) in parentheses.
|
||||
_improvementCost(improvement, prices) {
|
||||
const costs = tileImprovementResourceCost(improvement);
|
||||
const $cost = $("<span class='improvement-cost'></span>");
|
||||
const items = [];
|
||||
for (const id of ["steel", "energy", "hightech"]) {
|
||||
if (!(costs[id] > 0)) continue;
|
||||
const proto = resourceById(id);
|
||||
items.push(
|
||||
$("<span class='cost-res'></span>").append(
|
||||
$("<img class='cost-icon'>")
|
||||
.attr("src", `assets/${proto.icon}`)
|
||||
.attr("alt", proto.name)
|
||||
.attr("title", proto.name),
|
||||
$("<span></span>").text(formatResourceAmount(id, costs[id]))
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
$("<span class='cost-money'></span>").text(
|
||||
`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`
|
||||
)
|
||||
);
|
||||
items.forEach((item, index) => {
|
||||
if (index > 0) $cost.append(" ");
|
||||
$cost.append(item);
|
||||
});
|
||||
return $cost;
|
||||
},
|
||||
|
||||
// The same cost as plain text, for a tooltip: the resource amounts then the
|
||||
// market price.
|
||||
_improvementCostText(improvement, prices) {
|
||||
const costs = tileImprovementResourceCost(improvement);
|
||||
const parts = [];
|
||||
for (const id of ["steel", "energy", "hightech"]) {
|
||||
if (costs[id] > 0) {
|
||||
parts.push(`${formatResourceAmount(id, costs[id])} ${resourceById(id).name.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
parts.push(`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`);
|
||||
return parts.join(", ");
|
||||
},
|
||||
|
||||
// The daily resource upkeep of a tile work, for its tooltip. Transport wears
|
||||
// per tile rather than as a share of its cost.
|
||||
_improvementUpkeepText(improvement) {
|
||||
if (transportImprovement(improvement.id)) {
|
||||
const steel = RESOURCE_RULES.transportUpkeepSteel[improvement.id] || 0;
|
||||
return `${formatResourceAmount("steel", steel)} steel`;
|
||||
}
|
||||
const upkeep = protoUpkeep(improvement, improvement.buildCost || 0);
|
||||
const parts = [];
|
||||
for (const id of ["steel", "energy", "hightech"]) {
|
||||
if (upkeep[id] > 0) {
|
||||
parts.push(`${formatResourceAmount(id, upkeep[id])} ${resourceById(id).name.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : "no upkeep";
|
||||
},
|
||||
|
||||
// One square icon button per improvement, grouped under a category heading:
|
||||
// military first, then transportation, then economic (resource producers),
|
||||
// plus a control to remove the one already standing on the tile. Shown only
|
||||
// on a controlled land tile; an unaffordable improvement is greyed out.
|
||||
_renderImprovementActions(coords, buildable, current) {
|
||||
const $row = $("#tile-improvement-actions");
|
||||
if (!$row.length) return;
|
||||
@@ -1036,23 +1104,58 @@ export const panelMethods = {
|
||||
if (!buildable) return;
|
||||
const budget = (this.snapshot.viewerStats || {}).budget || 0;
|
||||
const testing = !!this.testing;
|
||||
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
|
||||
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
|
||||
const prices = marketPricesFrom(this.snapshot);
|
||||
const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
|
||||
const standingProto = standing ? tileImprovementById(standing.id) : null;
|
||||
const isResource = !!(standingProto && standingProto.resource);
|
||||
|
||||
// Military tile works. They cost resources and take production time, so the
|
||||
// tooltip names the expected hours alongside the upkeep.
|
||||
const military = [];
|
||||
for (const improvement of TILE_IMPROVEMENTS) {
|
||||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === improvement.id);
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const title = standing && !built
|
||||
? `${improvement.name} — another improvement already stands here`
|
||||
: affordable
|
||||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`
|
||||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "tile-btn",
|
||||
title,
|
||||
disabled: !!standing || !affordable,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
|
||||
military.push($button);
|
||||
}
|
||||
|
||||
// Transportation: roads and railways, bought outright and laid at once.
|
||||
const transport = [];
|
||||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||||
const isCurrent = current === improvement.id;
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const title = isCurrent
|
||||
? `${improvement.name} — already built on this tile`
|
||||
: affordable
|
||||
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/tile·h upkeep`
|
||||
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
|
||||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day upkeep`
|
||||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "transport-btn",
|
||||
costLabel: `€${compact(improvement.buildCost)}`,
|
||||
title,
|
||||
disabled: isCurrent || !affordable,
|
||||
current: isCurrent,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
|
||||
$row.append($button);
|
||||
transport.push($button);
|
||||
// A local test game may raise it for free and at once.
|
||||
if (testing && !isCurrent) {
|
||||
const $free = this._improvementButton(improvement, {
|
||||
@@ -1063,48 +1166,78 @@ export const panelMethods = {
|
||||
current: false,
|
||||
});
|
||||
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
|
||||
$row.append($free);
|
||||
transport.push($free);
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
const name = transportImprovement(current) ? transportImprovement(current).name : current;
|
||||
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
.on("click", () => this._removeImprovement(coords));
|
||||
$row.append($remove);
|
||||
const proto = transportImprovement(current);
|
||||
const name = proto ? proto.name : current;
|
||||
transport.push(
|
||||
$("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
.on("click", () => this._removeImprovement(coords))
|
||||
);
|
||||
}
|
||||
|
||||
// Military tile improvements. They cost money and take production time, so
|
||||
// the tooltip names the expected hours alongside the upkeep.
|
||||
const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
|
||||
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
|
||||
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
|
||||
for (const improvement of TILE_IMPROVEMENTS) {
|
||||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(military && military.id === improvement.id);
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const title = military && !built
|
||||
? `${improvement.name} — another improvement already stands here`
|
||||
: affordable
|
||||
? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build`
|
||||
: `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "tile-btn",
|
||||
costLabel: `€${compact(improvement.buildCost)}`,
|
||||
title,
|
||||
disabled: !!military || !affordable,
|
||||
current: built,
|
||||
});
|
||||
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
|
||||
$row.append($button);
|
||||
// Economic: resource producers. They live on their own land, take
|
||||
// production time and feed the delivery graph; only one may stand on a
|
||||
// tile.
|
||||
const economic = [];
|
||||
if (!isResource) {
|
||||
for (const producer of RESOURCE_BUILDINGS) {
|
||||
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === producer.id);
|
||||
const affordable = budget >= producer.buildCost;
|
||||
const buildHours = ECONOMY.trainingHours(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)
|
||||
: -1;
|
||||
const locked = techIndex >= 0 && !this._researched.has(techIndex);
|
||||
const out = producer.resource === "energy"
|
||||
? formatEnergy(producer.outputPerDay)
|
||||
: `${compact(producer.outputPerDay)} ${producer.resource}/day`;
|
||||
const title = locked
|
||||
? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
|
||||
: standing && !built
|
||||
? `${producer.name} — another building already stands here`
|
||||
: affordable
|
||||
? `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
|
||||
`${hours(buildHours)} to build`
|
||||
: `${producer.name} — needs ${this._improvementCostText(producer, prices)}`;
|
||||
const $button = this._improvementButton(producer, {
|
||||
className: "tile-btn resource-btn",
|
||||
title,
|
||||
disabled: !!standing || !affordable || locked,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildTileImprovement(coords, producer.id));
|
||||
economic.push($button);
|
||||
}
|
||||
}
|
||||
if (military) {
|
||||
const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id;
|
||||
if (standing) {
|
||||
const name = standingProto ? standingProto.name : standing.id;
|
||||
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
.on("click", () => this._removeTileImprovement(coords));
|
||||
$row.append($remove);
|
||||
// A standing work is removed from its own category.
|
||||
if (isResource) economic.push($remove);
|
||||
else military.push($remove);
|
||||
}
|
||||
|
||||
for (const group of [
|
||||
{ label: "Military", buttons: military },
|
||||
{ label: "Transportation", buttons: transport },
|
||||
{ label: "Economic", buttons: economic },
|
||||
]) {
|
||||
if (group.buttons.length === 0) continue;
|
||||
const $group = $("<div class='improvement-group'></div>");
|
||||
$group.append($("<div class='improvement-category'></div>").text(group.label));
|
||||
const $buttons = $("<div class='improvement-row'></div>");
|
||||
for (const $button of group.buttons) $buttons.append($button);
|
||||
$group.append($buttons);
|
||||
$row.append($group);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1216,66 +1349,66 @@ export const panelMethods = {
|
||||
$container.append(ethnicityChart(makeup, this.civilisations, 104));
|
||||
},
|
||||
|
||||
// The base GDP per capita of a claimable tile plus every factor that shapes
|
||||
// it, mirroring the server's getTileGdpPerCapita through the shared helper.
|
||||
// The region that develops the tile supplies the building bonus; government
|
||||
// and research supply the nation-wide one.
|
||||
_tileGdpBreakdown(coords, owner, city, tile, caches = null) {
|
||||
// The resources a tile produces in a day, each amount already carrying the
|
||||
// tile's production modifiers, plus the factor list behind it. Food comes from
|
||||
// the people on the tile; every other resource from the work standing there.
|
||||
_tileResourceProduction(coords, owner, city, tile) {
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
const regionCity =
|
||||
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
|
||||
const regionalModifier = regionCity
|
||||
? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity))
|
||||
: 0;
|
||||
const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner));
|
||||
const factors = gdpPerCapitaFactors(tile, {
|
||||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||||
nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)),
|
||||
pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
|
||||
battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
|
||||
regionalMultiplier: 1 + regionalModifier,
|
||||
regionalLabel: regionCity ? regionCity.name : null,
|
||||
nationalMultiplier: 1 + nationalModifier,
|
||||
portGdp: regionCity
|
||||
? memo(caches, "portGdp", regionCity.id, () => this._regionPortGdp(regionCity))
|
||||
: 0,
|
||||
naturalGrowth: memo(caches, "growth", k, () => this._naturalTileGdpGrowth(k)),
|
||||
});
|
||||
return {
|
||||
base: ECONOMY.baseGdpPerCapita,
|
||||
factors,
|
||||
total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
|
||||
};
|
||||
const factors = this._productionFactors(coords, owner, city, tile, regionCity);
|
||||
const multiplier = productionMultiplier(factors);
|
||||
const amounts = { energy: 0, steel: 0, food: 0, luxury: 0, hightech: 0 };
|
||||
const population = this._population.get(k) || 0;
|
||||
let foodMultiplier = 1;
|
||||
if (population > 0 && tile) {
|
||||
foodMultiplier = regionCity ? this._regionFoodMultiplier(regionCity) : 1;
|
||||
const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
|
||||
amounts.food = foodNeedPerDay(population) * factor * foodMultiplier * multiplier;
|
||||
}
|
||||
const improvement = this.map.tileImprovements.get(k);
|
||||
const proto = improvement ? tileImprovementById(improvement.id) : null;
|
||||
if (proto && proto.resource && proto.outputPerDay) {
|
||||
amounts[proto.resource] += proto.outputPerDay * multiplier;
|
||||
}
|
||||
return { amounts, factors, foodMultiplier };
|
||||
},
|
||||
|
||||
// The flat GDP per capita a region's ports add to every tile it covers,
|
||||
// mirroring the server: 1000 per port level for each sea tile beside the city.
|
||||
_regionPortGdp(city) {
|
||||
// The production modifiers of one tile: terrain, settlement, war and the
|
||||
// region's and nation's own bonuses. Mirrors the server's `productionFactors`
|
||||
// inputs so the tile panel and the simulation explain the same figure.
|
||||
_productionFactors(coords, owner, city, tile, regionCity) {
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
return productionFactors(tile, {
|
||||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||||
pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
|
||||
battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
|
||||
trench: this.map.trenches.has(k),
|
||||
regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
|
||||
regionalLabel: regionCity ? regionCity.name : null,
|
||||
nationalMultiplier: 1 + this._nationalProductionModifier(owner),
|
||||
});
|
||||
},
|
||||
|
||||
// The extra food a region's ports bring in, mirroring the server: 5% per
|
||||
// adjacent sea tile per port level.
|
||||
_regionFoodMultiplier(city) {
|
||||
const protos = this.snapshot.buildings || [];
|
||||
let bonus = 0;
|
||||
for (const [index, level] of Object.entries(city.buildings || {})) {
|
||||
const proto = protos[index];
|
||||
if (proto && proto.mechanic === "port_gdp") {
|
||||
bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
|
||||
if (proto && proto.mechanic === "port_production") {
|
||||
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||||
}
|
||||
}
|
||||
if (bonus === 0) return 0;
|
||||
if (bonus === 0) return 1;
|
||||
let sea = 0;
|
||||
for (const neighbour of this.map.topology.neighbours(city.coords[0], city.coords[1])) {
|
||||
const tile = this.map.getTile(neighbour);
|
||||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||||
}
|
||||
return bonus * sea;
|
||||
},
|
||||
|
||||
// The flat GDP per capita a tile has gained since January 2000, mirroring the
|
||||
// server: its opening figure times the daily rate, once per day elapsed.
|
||||
_naturalTileGdpGrowth(k) {
|
||||
const baseline = this._gdpBaseline.get(k) || 0;
|
||||
if (!baseline) return 0;
|
||||
const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
|
||||
const days = Math.floor(Math.max(0, totalHours) / HOURS_PER_DAY);
|
||||
return baseline * ECONOMY.naturalGdpGrowthPerDay * days;
|
||||
return 1 + bonus * sea;
|
||||
},
|
||||
|
||||
// -------------------------------------------------------- map modes -----
|
||||
@@ -1301,16 +1434,21 @@ export const panelMethods = {
|
||||
},
|
||||
|
||||
// One figure per land tile for an economic mode, with the range the legend
|
||||
// and the colour scale need. GDP per capita mirrors the tile panel's formula;
|
||||
// population is the snapshot's raw per-tile count.
|
||||
// and the colour scale need. GDP per capita is the region's, so every tile of
|
||||
// a region shares one colour; population is the raw per-tile count.
|
||||
_computeEconomicValues(mode) {
|
||||
const values = new Map();
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
|
||||
const cities = this.snapshot.cities || [];
|
||||
const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c]));
|
||||
const caches = mode === "gdp" ? {} : null;
|
||||
const gdpPerCapita = new Map();
|
||||
if (mode === "gdp") {
|
||||
for (const city of this.snapshot.cities || []) {
|
||||
const stats = this._cityStats(city.id);
|
||||
const population = stats.population || 0;
|
||||
gdpPerCapita.set(city.id, population > 0 ? (stats.gdp || 0) / population : 0);
|
||||
}
|
||||
}
|
||||
for (const k in this.map.tiles) {
|
||||
const tile = this.map.tiles[k];
|
||||
if (isWaterTile(tile)) continue;
|
||||
@@ -1318,10 +1456,7 @@ export const panelMethods = {
|
||||
if (mode === "population") {
|
||||
value = this._population.get(k) || 0;
|
||||
} else {
|
||||
const coords = parseKey(k);
|
||||
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
|
||||
const city = cityByKey.get(k) || null;
|
||||
value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total;
|
||||
value = gdpPerCapita.get(this._tileRegion.get(k)) || 0;
|
||||
}
|
||||
values.set(k, value);
|
||||
if (value < min) min = value;
|
||||
@@ -1331,34 +1466,35 @@ export const panelMethods = {
|
||||
return { values, min, max };
|
||||
},
|
||||
|
||||
// The GDP bonus a single city's own buildings give.
|
||||
_cityGdpModifier(city) {
|
||||
// The production bonus a single city's own buildings give.
|
||||
_cityProductionModifier(city) {
|
||||
let total = 0;
|
||||
for (const indexStr of Object.keys(city.buildings || {})) {
|
||||
const proto = BUILDINGS[Number(indexStr)];
|
||||
if (!proto) continue;
|
||||
const level = city.buildings[indexStr];
|
||||
for (const e of proto.effects) {
|
||||
if (e.stat === EFFECT_GDP) total += effectValue(e, level);
|
||||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, level);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// The GDP bonus a nation gets from its government and researched technology.
|
||||
_nationalGdpModifier(civ) {
|
||||
// The production bonus a nation gets from its government and researched
|
||||
// technology.
|
||||
_nationalProductionModifier(civ) {
|
||||
let total = 0;
|
||||
const government = GOVERNMENTS[(this.snapshot.government || [])[civ]];
|
||||
if (government) {
|
||||
for (const e of government.effects) {
|
||||
if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
|
||||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
|
||||
}
|
||||
}
|
||||
for (const index of (this.snapshot.researched || [])[civ] || []) {
|
||||
const tech = TECHNOLOGIES[index];
|
||||
if (!tech) continue;
|
||||
for (const e of tech.effects) {
|
||||
if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
|
||||
if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -1377,53 +1513,64 @@ export const panelMethods = {
|
||||
return false;
|
||||
},
|
||||
|
||||
// Lists the tile's GDP per capita and each factor behind it, so a click
|
||||
// explains exactly why the figure is what it is.
|
||||
_renderTileGdp(breakdown) {
|
||||
const $container = $("#tile-gdp").empty();
|
||||
if (!breakdown) {
|
||||
$container.text("GDP/capita: —");
|
||||
// The tile panel's headline: what the tile actually produces, one row per
|
||||
// resource (nothing is shown for a resource the tile does not make), with the
|
||||
// modifiers that shape it in a hover tooltip.
|
||||
_renderTileProduction(production) {
|
||||
const $container = $("#tile-production").empty();
|
||||
const rows = [];
|
||||
for (const resource of RESOURCES) {
|
||||
const amount = production.amounts[resource.id] || 0;
|
||||
if (!(amount > 0)) continue;
|
||||
const notes = [];
|
||||
for (const factor of production.factors) {
|
||||
if (factor.multiplier !== undefined) {
|
||||
if (factor.multiplier === 1) continue;
|
||||
notes.push(`${factor.label}: ×${factor.multiplier.toFixed(2)}`);
|
||||
} else if (factor.amount) {
|
||||
const sign = factor.amount < 0 ? "−" : "+";
|
||||
notes.push(`${factor.label}: ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(factor.amount)))}`);
|
||||
}
|
||||
}
|
||||
if (resource.id === "food" && production.foodMultiplier !== 1) {
|
||||
notes.push(`Port food: ×${production.foodMultiplier.toFixed(2)}`);
|
||||
}
|
||||
const $row = $("<div class='tile-prod-row'></div>");
|
||||
$row.append(
|
||||
$("<img class='resource-icon' width='16' height='16'>")
|
||||
.attr("src", `assets/${resource.icon}`)
|
||||
.attr("alt", resource.name),
|
||||
$("<span class='tile-prod-name'></span>").text(resource.name),
|
||||
$("<span class='tile-prod-value'></span>").text(
|
||||
`${formatResourceAmount(resource.id, amount)}/day`
|
||||
)
|
||||
);
|
||||
if (notes.length > 0) $row.attr("title", notes.join("\n"));
|
||||
rows.push($row);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
$container.append($("<div class='tile-prod-empty'></div>").text("No production"));
|
||||
return;
|
||||
}
|
||||
const $mods = $("<div class='tile-gdp-mods'></div>");
|
||||
if (!this._gdpExpanded) $mods.addClass("hidden");
|
||||
$mods.append(
|
||||
$("<div class='tile-gdp-mod'></div>").text(`Base €${groupDigits(breakdown.base)}`)
|
||||
);
|
||||
let modifierCount = 0;
|
||||
for (const factor of breakdown.factors) {
|
||||
// A modifier that changes nothing (×1 or ±€0) is left out.
|
||||
if (factor.amount !== undefined) {
|
||||
if (factor.amount === 0) continue;
|
||||
} else if (factor.multiplier === 1) {
|
||||
continue;
|
||||
}
|
||||
const value =
|
||||
factor.amount !== undefined
|
||||
? `${factor.amount < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(factor.amount)))}`
|
||||
: `×${factor.multiplier.toFixed(2)}`;
|
||||
$mods.append(
|
||||
$("<div class='tile-gdp-mod'></div>").text(`${factor.label} ${value}`)
|
||||
);
|
||||
modifierCount += 1;
|
||||
for (const $row of rows) $container.append($row);
|
||||
},
|
||||
|
||||
// The only place GDP per capita is shown: the region the tile belongs to. A
|
||||
// tile never gets a per-tile figure of its own.
|
||||
_renderRegionGdp(regionCity) {
|
||||
const $container = $("#tile-gdp").empty();
|
||||
if (!regionCity) {
|
||||
$container.text("Region GDP/capita: —");
|
||||
return;
|
||||
}
|
||||
const $head = $("<div class='tile-gdp-head'></div>").text(
|
||||
`GDP/capita: €${groupDigits(Math.round(breakdown.total))}`
|
||||
const stats = this._cityStats(regionCity.id);
|
||||
const population = stats.population || 0;
|
||||
const perCapita = population > 0 ? (stats.gdp || 0) / population : 0;
|
||||
$container.append(
|
||||
$("<div class='tile-gdp-head'></div>").text(
|
||||
`Region GDP/capita: ${currencySymbol()}${groupDigits(Math.round(perCapita))}`
|
||||
)
|
||||
);
|
||||
if (modifierCount > 0) {
|
||||
$head.addClass("tile-gdp-toggle");
|
||||
$head.append(
|
||||
$("<span class='tile-gdp-caret'></span>").text(this._gdpExpanded ? "▾" : "▸")
|
||||
);
|
||||
$head.on("click", () => {
|
||||
this._gdpExpanded = !this._gdpExpanded;
|
||||
$mods.toggleClass("hidden", !this._gdpExpanded);
|
||||
$head.find(".tile-gdp-caret").text(this._gdpExpanded ? "▾" : "▸");
|
||||
});
|
||||
$container.append($head, $mods);
|
||||
} else {
|
||||
$container.append($head);
|
||||
}
|
||||
},
|
||||
|
||||
// The tile/city panel and the unit panel are independent docks, so either
|
||||
@@ -1484,12 +1631,19 @@ export const panelMethods = {
|
||||
queue: this._trainingQueue(city.id),
|
||||
civs: this.civilisations,
|
||||
ethnicMakeup: cityStats.ethnicMakeup || [],
|
||||
// The region's private-sector cash basket and the currencies it names.
|
||||
cash: cityStats.cash || {},
|
||||
cashValue: cityStats.cashValue || 0,
|
||||
debt: cityStats.debt || 0,
|
||||
currencies: stats.currencies || [],
|
||||
approval: cityStats.approval,
|
||||
// The region's tax base and approval price an upgrade's expected income.
|
||||
base: cityStats.base || 0,
|
||||
// Construction technology shortens the displayed build times.
|
||||
constructionSpeed: stats.constructionSpeed || 0,
|
||||
taxMultiplier: this._taxMultiplier(cityStats.approval),
|
||||
// Today's market prices, so build costs are quoted at current rates.
|
||||
prices: marketPricesFrom(this.snapshot),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1509,6 +1663,7 @@ export const panelMethods = {
|
||||
this._updateCityProgress(queue);
|
||||
const cityStats = this._cityStats(this.selectedCityId);
|
||||
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
|
||||
this.cityModal.prices = marketPricesFrom(this.snapshot);
|
||||
const city = this._city(this.selectedCityId);
|
||||
if (city) {
|
||||
this.cityModal.refreshTrainList(this._trainableUnits(city));
|
||||
@@ -1527,6 +1682,12 @@ export const panelMethods = {
|
||||
{ base: cityStats.base || 0, approval: cityStats.approval }
|
||||
);
|
||||
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
|
||||
this.cityModal.renderEconomy(
|
||||
cityStats.cash || {},
|
||||
cityStats.cashValue || 0,
|
||||
stats.currencies || [],
|
||||
cityStats.debt || 0
|
||||
);
|
||||
},
|
||||
|
||||
_updateCityProgress(queue) {
|
||||
@@ -1595,10 +1756,40 @@ export const panelMethods = {
|
||||
this._diplomacyInfo(),
|
||||
this._conflicts,
|
||||
this._politicsInfo(),
|
||||
this._techInfo()
|
||||
this._techInfo(),
|
||||
this._resourcesInfo(),
|
||||
this._moneyInfo()
|
||||
);
|
||||
},
|
||||
|
||||
// The money the Economy tab needs: the viewer's currency, everyone else's,
|
||||
// the exchange rates, the central bank and the aggregate private-sector cash.
|
||||
_moneyInfo() {
|
||||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||||
return {
|
||||
viewerCiv: this.localCiv,
|
||||
currency: stats.currency || null,
|
||||
currencies: stats.currencies || [],
|
||||
exchangeRates: stats.exchangeRates || [],
|
||||
centralBank: stats.centralBank || { rate: 0, reserves: [] },
|
||||
privateCash: stats.privateCash || 0,
|
||||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||||
};
|
||||
},
|
||||
|
||||
// Everything the Resources tab needs: the nation's own stores, needs and
|
||||
// trade from the viewer stats, plus the world market's prices, the world's
|
||||
// supply and demand, and every nation's import/export ledger.
|
||||
_resourcesInfo() {
|
||||
const stats = (this.snapshot && this.snapshot.viewerStats) || {};
|
||||
return {
|
||||
summary: stats.resources || null,
|
||||
market: (this.snapshot && this.snapshot.resourceMarket) || [],
|
||||
trade: (this.snapshot && this.snapshot.resourceTrade) || [],
|
||||
civs: this.civilisations,
|
||||
};
|
||||
},
|
||||
|
||||
// The focused-research figures the technology tab reads from the snapshot.
|
||||
_techInfo() {
|
||||
const snapshot = this.snapshot || {};
|
||||
@@ -1628,7 +1819,9 @@ export const panelMethods = {
|
||||
this._diplomacyInfo(),
|
||||
this._conflicts,
|
||||
this._politicsInfo(),
|
||||
this._techInfo()
|
||||
this._techInfo(),
|
||||
this._resourcesInfo(),
|
||||
this._moneyInfo()
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1703,11 +1896,12 @@ export const panelMethods = {
|
||||
},
|
||||
|
||||
_finances(stats) {
|
||||
if (!stats || !stats.breakdown) return null;
|
||||
// The region figures are global, so the Cities tab can show every nation's;
|
||||
// the panel filters them to the viewer.
|
||||
if (!stats) return null;
|
||||
// The month reports page back through the stored budget digests; the region
|
||||
// figures are global, so the Cities tab can show every nation's, and the
|
||||
// panel filters them to the viewer.
|
||||
return {
|
||||
...stats.breakdown,
|
||||
months: stats.budgetMonths || [],
|
||||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -47,6 +47,17 @@ export class MapView {
|
||||
this.$targets = layers.targets && layers.targets.length
|
||||
? layers.targets
|
||||
: $("#layer-targets");
|
||||
// Resource storage nodes and delivery legs, drawn in the economic map
|
||||
// modes so the delivery graph is never visible on the political map.
|
||||
this.$resources = layers.resources && layers.resources.length
|
||||
? layers.resources
|
||||
: $("#layer-resources");
|
||||
if (!this.$resources.length) {
|
||||
this.$resources = $('<div id="layer-resources" class="layer"></div>');
|
||||
if (this.$targets && this.$targets.length) this.$targets.after(this.$resources);
|
||||
else this.$world.append(this.$resources);
|
||||
}
|
||||
this.resourceGraph = { nodes: [], links: [] };
|
||||
this.$entities = layers.entities;
|
||||
// City names live on their own layer above the entities so they are never
|
||||
// hidden behind a unit or city marker.
|
||||
@@ -78,7 +89,7 @@ export class MapView {
|
||||
this.$viewport[0].style.setProperty("--camera-tilt", String(CAMERA_TILT));
|
||||
for (const $layer of [
|
||||
this.$terrain, this.$roads, this.$borders, this.$fog,
|
||||
this.$highlight, this.$paths, this.$targets,
|
||||
this.$highlight, this.$paths, this.$targets, this.$resources,
|
||||
]) {
|
||||
if ($layer && $layer.length) $layer.addClass("tilted");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { parseColour } from "./utils.js";
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
export const ECONOMIC_GRADIENT = [
|
||||
"#ffea46",
|
||||
@@ -31,7 +32,7 @@ export const ECONOMIC_MODES = {
|
||||
gdp: {
|
||||
id: "gdp",
|
||||
label: "GDP per capita",
|
||||
format: (value) => `€${groupDigits(Math.round(value))}`,
|
||||
format: (value) => `${currencySymbol()}${groupDigits(Math.round(value))}`,
|
||||
},
|
||||
population: {
|
||||
id: "population",
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
// and drives the economic fill and its legend. The political names themselves
|
||||
// live in ./political.js; this file only decides what is on screen.
|
||||
|
||||
import { HEX_W } from "../../../shared/hex.js";
|
||||
import { HEX_W, mapToLocal } from "../../../shared/hex.js";
|
||||
import { POLITICAL_ZOOM } from "./constants.js";
|
||||
import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
|
||||
import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
|
||||
import { isWaterTile } from "./textures.js";
|
||||
import { makeSegment } from "./utils.js";
|
||||
import { RESOURCES } from "../../../shared/data/resources.js";
|
||||
|
||||
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
|
||||
|
||||
@@ -45,6 +47,47 @@ export const modeMethods = {
|
||||
return `${this.economicKind}:${this.economicValues.size}:${Math.round(sum)}`;
|
||||
},
|
||||
|
||||
// ------------------------------------------------- delivery graph ------
|
||||
|
||||
// Replaces the delivery graph shipped in the snapshot. It is only drawn in
|
||||
// the economic modes, so a political or terrain map never reveals it.
|
||||
setResourceGraph(graph) {
|
||||
if (!graph) return;
|
||||
this.resourceGraph = {
|
||||
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
|
||||
links: Array.isArray(graph.links) ? graph.links : [],
|
||||
};
|
||||
this._renderResourceGraph();
|
||||
},
|
||||
|
||||
// Draws one line per delivery leg and a dot on every storage node. The layer
|
||||
// shares the world's coordinate system, so pan and zoom move it for free.
|
||||
_renderResourceGraph() {
|
||||
if (!this.$resources || !this.$resources.length) return;
|
||||
const graph = this.resourceGraph || { nodes: [], links: [] };
|
||||
if (!this.economic || graph.links.length === 0) {
|
||||
if (this.$resources[0].firstChild) this.$resources.empty();
|
||||
return;
|
||||
}
|
||||
const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const link of graph.links) {
|
||||
const [fromX, fromY, toX, toY, resource] = link;
|
||||
const a = mapToLocal(fromX, fromY);
|
||||
const b = mapToLocal(toX, toY);
|
||||
fragment.appendChild(makeSegment(a, b, colours[resource] || "#ffffff", 3));
|
||||
}
|
||||
for (const node of graph.nodes) {
|
||||
const point = mapToLocal(node[0], node[1]);
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "resource-node";
|
||||
dot.style.left = `${point.x}px`;
|
||||
dot.style.top = `${point.y}px`;
|
||||
fragment.appendChild(dot);
|
||||
}
|
||||
this.$resources.empty()[0].appendChild(fragment);
|
||||
},
|
||||
|
||||
// Political shows when the user asked for it, or while terrain mode is
|
||||
// zoomed far enough out. Economic modes are exclusive with it.
|
||||
_effectivePolitical() {
|
||||
@@ -109,6 +152,7 @@ export const modeMethods = {
|
||||
}
|
||||
if (this.political) this._renderPoliticalLabels();
|
||||
else if (this.$politicalLabels) this.$politicalLabels.empty();
|
||||
this._renderResourceGraph();
|
||||
this._updateLegend();
|
||||
},
|
||||
|
||||
|
||||
@@ -12,4 +12,4 @@ export { ConfirmModal } from "./modals/confirm.js";
|
||||
export { NewsModal } from "./modals/news.js";
|
||||
export { MapModesModal } from "./modals/map_modes.js";
|
||||
export { CapabilityModal } from "./modals/capabilities.js";
|
||||
export { describeNews } from "./modals/format.js";
|
||||
export { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "./modals/format.js";
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// per-city cost. `breakdown` mirrors the shape both the national and the
|
||||
// regional economy use, so one renderer serves them both.
|
||||
|
||||
import { percent, signedMoney, SOURCE_LABELS } from "./format.js";
|
||||
import { percent, signedMoney, money, SOURCE_LABELS } from "./format.js";
|
||||
import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
// Draws the table into `$list`. `expanded` is the set of open building types,
|
||||
// mutated in place; `onToggle` is called after a caret flips so the caller can
|
||||
@@ -21,6 +24,7 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
const campaignSources = campaigns.sources || [];
|
||||
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
|
||||
const transportSources = transport.sources || [];
|
||||
const resourceMoney = breakdown.resourceMoney || [];
|
||||
const net = breakdown.net || 0;
|
||||
|
||||
$list.empty();
|
||||
@@ -95,12 +99,132 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
|
||||
}
|
||||
}
|
||||
|
||||
// What the nation spent buying each resource, and earned selling it to other
|
||||
// countries, over the last day, shown as an hourly average.
|
||||
if (resourceMoney.length > 0) {
|
||||
$body.append(sectionRow("Resource trade"));
|
||||
for (const entry of resourceMoney) {
|
||||
const proto = resourceById(entry.id);
|
||||
const name = proto ? proto.name : entry.id;
|
||||
if (entry.bought > 0) {
|
||||
$body.append(detailRow(`Bought ${name}`, -entry.bought / 24, "expense-detail"));
|
||||
}
|
||||
if (entry.sold > 0) {
|
||||
$body.append(detailRow(`Sold ${name}`, entry.sold / 24, "income-detail"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $table = $("<table class='budget-table'></table>");
|
||||
$table.append($body);
|
||||
$table.append($("<tfoot></tfoot>").append(totalRow("TOTAL", net)));
|
||||
$list.append($table);
|
||||
}
|
||||
|
||||
// Renders a whole month's actual budget: the tax and resource sales earned, the
|
||||
// money each expense category spent, and the resource-trade memo. `report` is
|
||||
// the compact shape the snapshot ships (category ids, not labels). The cash
|
||||
// spent is the headline; when a category consumed materials worth more than the
|
||||
// cash it paid (the stores covered the rest), the row notes the full market
|
||||
// value. The treasury itself lives on the panel's subtitle, not in the table.
|
||||
export function renderMonthlyBudget($list, report, { expanded = new Set(), onToggle = null } = {}) {
|
||||
if (!$list || !$list.length) return;
|
||||
$list.empty();
|
||||
if (!report) return;
|
||||
const $body = $("<tbody></tbody>");
|
||||
|
||||
$body.append(sectionRow("Income"));
|
||||
$body.append(monthRow("Tax income", report.income, "income", null));
|
||||
if (report.sales > 0) {
|
||||
$body.append(monthRow("Resource sales", report.sales, "income-detail", null));
|
||||
}
|
||||
|
||||
$body.append(sectionRow("Expenses"));
|
||||
const byId = new Map((report.expenses || []).map((entry) => [entry[0], entry]));
|
||||
const sections = [];
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||||
const entry = byId.get(category.id);
|
||||
if (!entry) continue;
|
||||
const last = sections[sections.length - 1];
|
||||
if (!last || last.group !== category.group) {
|
||||
sections.push({ group: category.group, rows: [] });
|
||||
}
|
||||
sections[sections.length - 1].rows.push({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
cash: entry[1],
|
||||
economic: entry[2],
|
||||
buys: entry[3] || [],
|
||||
});
|
||||
}
|
||||
for (const section of sections) {
|
||||
$body.append(subsectionRow(section.group));
|
||||
for (const row of section.rows) {
|
||||
const note = row.economic > row.cash + 1
|
||||
? `materials valued at ${currencySymbol()}${groupDigits(Math.round(row.economic))}`
|
||||
: null;
|
||||
const isOpen = expanded.has(row.id);
|
||||
const expandable = row.buys.length > 0;
|
||||
const $tr = monthRow(row.label, -row.cash, "expense-detail", note);
|
||||
if (expandable) {
|
||||
const $name = $tr.find(".budget-name");
|
||||
$name.addClass("clickable");
|
||||
$name.prepend($("<span class='budget-caret'></span>").text(isOpen ? "▾" : "▸"));
|
||||
$name.on("click", () => {
|
||||
if (expanded.has(row.id)) expanded.delete(row.id);
|
||||
else expanded.add(row.id);
|
||||
if (onToggle) onToggle(row.id);
|
||||
});
|
||||
}
|
||||
$body.append($tr);
|
||||
if (isOpen) {
|
||||
for (const [resource, amount] of row.buys) {
|
||||
const proto = resourceById(resource);
|
||||
const name = proto ? proto.name : resource;
|
||||
$body.append(monthRow(` ${name}`, -amount, "expense-detail", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trade = report.resourceTrade || [];
|
||||
if (trade.length > 0) {
|
||||
$body.append(sectionRow("Resource trade"));
|
||||
for (const [id, bought, sold] of trade) {
|
||||
const proto = resourceById(id);
|
||||
const name = proto ? proto.name : id;
|
||||
if (bought > 0) $body.append(monthRow(`Bought ${name}`, -bought, "expense-detail", null));
|
||||
if (sold > 0) $body.append(monthRow(`Sold ${name}`, sold, "income-detail", null));
|
||||
}
|
||||
}
|
||||
|
||||
const $table = $("<table class='budget-table'></table>");
|
||||
$table.append($body);
|
||||
$table.append($("<tfoot></tfoot>").append(monthTotalRow("TOTAL", report.net)));
|
||||
$list.append($table);
|
||||
}
|
||||
|
||||
function monthRow(label, value, cls, note) {
|
||||
const $tr = $("<tr></tr>").addClass(cls);
|
||||
const $name = $("<td class='budget-name'></td>").text(label);
|
||||
if (note) $name.append($("<div class='budget-note'></div>").text(note));
|
||||
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
|
||||
return $tr;
|
||||
}
|
||||
|
||||
function monthTotalRow(label, value) {
|
||||
const $tr = $("<tr class='budget-total'></tr>");
|
||||
$tr.append($("<td class='budget-name'></td>").text(label));
|
||||
$tr.append($("<td class='budget-value'></td>").text(money(value)));
|
||||
return $tr;
|
||||
}
|
||||
|
||||
function subsectionRow(label) {
|
||||
return $("<tr class='budget-subsection'></tr>").append(
|
||||
$("<td colspan='2'></td>").text(label)
|
||||
);
|
||||
}
|
||||
|
||||
function sectionRow(label) {
|
||||
return $("<tr class='budget-section'></tr>").append(
|
||||
$("<td colspan='2'></td>").text(label)
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
// city's production queue, so the train tab shows both kinds of order.
|
||||
|
||||
import { groupDigits, hours } from "../../../shared/text_format.js";
|
||||
import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue } from "../../../shared/data.js";
|
||||
import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue, resourceById } from "../../../shared/data.js";
|
||||
import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js";
|
||||
import {
|
||||
buildingBuildCost,
|
||||
buildingRefund,
|
||||
buildingUpkeep,
|
||||
} from "../../../shared/rules.js";
|
||||
import { buildingEffectText, signedMoney } from "./format.js";
|
||||
import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js";
|
||||
import { renderBudget } from "./budget.js";
|
||||
import { bindTabs } from "./tabs.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
export class CityModal {
|
||||
constructor() {
|
||||
@@ -34,6 +36,9 @@ export class CityModal {
|
||||
this.$budgetSub = $("#city-budget-sub");
|
||||
this.$budgetList = $("#city-budget-list");
|
||||
|
||||
this.$economySub = $("#city-economy-sub");
|
||||
this.$economyList = $("#city-economy-list");
|
||||
|
||||
this.cityId = 0;
|
||||
this.gdp = 0;
|
||||
this.coastal = false;
|
||||
@@ -41,6 +46,8 @@ export class CityModal {
|
||||
this.constructionSpeed = 0;
|
||||
// Local test game: each order also offers a free, instant button.
|
||||
this.testing = false;
|
||||
// Today's market prices, so build costs are quoted at current rates.
|
||||
this.prices = {};
|
||||
this._levels = {};
|
||||
this.protoNames = new Map();
|
||||
// Signature of the trainable set currently drawn, so a snapshot that did
|
||||
@@ -75,6 +82,7 @@ export class CityModal {
|
||||
this.coastal = !!view.coastal;
|
||||
this.constructionSpeed = view.constructionSpeed || 0;
|
||||
this.testing = !!view.testing;
|
||||
this.prices = view.prices || {};
|
||||
this.$title.text(`${city.name} — City`);
|
||||
this._renderTrainList(this.gdp, view.trainable || []);
|
||||
this.renderQueue(view.queue || []);
|
||||
@@ -87,9 +95,10 @@ export class CityModal {
|
||||
base: view.base || 0,
|
||||
approval: view.approval,
|
||||
});
|
||||
this.$budgetSub.text("Region net +€0/h");
|
||||
this.$budgetSub.text(`Region net +${currencySymbol()}0/h`);
|
||||
this.renderBudget(null);
|
||||
this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []);
|
||||
this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0);
|
||||
this.$modal.removeClass("hidden");
|
||||
}
|
||||
|
||||
@@ -117,6 +126,52 @@ export class CityModal {
|
||||
}
|
||||
}
|
||||
|
||||
// The region's private-sector cash: a basket of currencies it holds. Regions
|
||||
// are the game's private economic agents, so this is their own money, not the
|
||||
// government treasury.
|
||||
renderEconomy(cash, cashValue, currencies = [], debt = 0) {
|
||||
if (!this.$economyList || !this.$economyList.length) return;
|
||||
this.$economyList.empty();
|
||||
const byCode = new Map(currencies.map((entry) => [entry.code, entry]));
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
const owed = debt > 0
|
||||
? ` · Debt: ${currencySymbol()}${groupDigits(Math.round(debt))}`
|
||||
: "";
|
||||
this.$economySub.text(
|
||||
`Private-sector cash: ${currencySymbol()}${groupDigits(Math.round(cashValue || 0))}${owed}`
|
||||
);
|
||||
}
|
||||
this.$economyList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"This region is a private economic agent: it holds its own cash reserve, " +
|
||||
"trades with its neighbours and chases profit."
|
||||
)
|
||||
);
|
||||
const holdings = Object.entries(cash || {}).filter(([, amount]) => amount > 0);
|
||||
if (holdings.length === 0) {
|
||||
this.$economyList.append($("<div class='desc'></div>").text("No cash held."));
|
||||
return;
|
||||
}
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $head = $("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Currency"))
|
||||
.append($("<th class='numeric'></th>").text("Held"))
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const [code, amount] of holdings) {
|
||||
const currency = byCode.get(code);
|
||||
const name = currency ? `${currency.symbol} ${currency.name} (${code})` : code;
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(name))
|
||||
.append($("<td class='numeric'></td>").text(groupDigits(Math.round(amount))))
|
||||
);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$economyList.append($table);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- training --
|
||||
|
||||
// The fraction of the base build time a construction technology leaves, so a
|
||||
@@ -125,6 +180,34 @@ export class CityModal {
|
||||
return 1 / (1 + (this.constructionSpeed || 0));
|
||||
}
|
||||
|
||||
// The full price of a construction at today's market: the build cost plus its
|
||||
// material bill at current prices, whether or not the city already holds the
|
||||
// materials.
|
||||
_quotedCost(cost, proto, level) {
|
||||
return quotedBuildCost(cost, constructionResourceCost(proto, level, cost), this.prices);
|
||||
}
|
||||
|
||||
// The materials a construction spends, each with its icon, so a unit's cost
|
||||
// reads in resources first and the money figure stays secondary.
|
||||
_resourceCostEl(resources) {
|
||||
const $wrap = $("<span class='train-resources'></span>");
|
||||
for (const id of ["steel", "energy", "hightech"]) {
|
||||
const amount = resources[id] || 0;
|
||||
if (!(amount > 0)) continue;
|
||||
const proto = resourceById(id);
|
||||
$wrap.append(
|
||||
$("<span class='cost-res'></span>").append(
|
||||
$("<img class='cost-icon'>")
|
||||
.attr("src", `assets/${proto.icon}`)
|
||||
.attr("alt", proto.name)
|
||||
.attr("title", proto.name),
|
||||
$("<span></span>").text(formatResourceAmount(id, amount))
|
||||
)
|
||||
);
|
||||
}
|
||||
return $wrap;
|
||||
}
|
||||
|
||||
_renderTrainList(gdp, trainableUnits) {
|
||||
this.protoNames = new Map();
|
||||
this.$trainList.empty();
|
||||
@@ -140,7 +223,12 @@ export class CityModal {
|
||||
}
|
||||
const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor();
|
||||
button.append(
|
||||
$("<span></span>").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`)
|
||||
$("<span class='train-name'></span>").text(proto.name),
|
||||
this._resourceCostEl(constructionResourceCost(proto, 0, proto.cost)),
|
||||
$("<span class='train-price'></span>").text(
|
||||
`${currencySymbol()}${groupDigits(this._quotedCost(proto.cost, proto, 0))}`
|
||||
),
|
||||
$("<span class='train-time'></span>").text(hours(duration))
|
||||
);
|
||||
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
|
||||
signature.push(serverIndex);
|
||||
@@ -295,13 +383,13 @@ export class CityModal {
|
||||
const sign = amount >= 0 ? "+" : "−";
|
||||
$button.attr(
|
||||
"title",
|
||||
`Expected budget income ${sign}€${groupDigits(Math.round(Math.abs(amount)))}/h`
|
||||
`Expected budget income ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(amount)))}/h`
|
||||
);
|
||||
}
|
||||
|
||||
refreshBuildings(budget, levels, gdp = 0, queued = {}, region = null) {
|
||||
this._levels = levels || {};
|
||||
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
|
||||
this.$buildingsBudget.text(`Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`);
|
||||
for (const row of this.buildingRows) {
|
||||
const level = levels[row.index] || 0;
|
||||
const pending = queued[row.index] || 0;
|
||||
@@ -312,16 +400,16 @@ export class CityModal {
|
||||
row.$name.text(`${row.proto.name} — ${levelText}`);
|
||||
row.$effects.text(buildingEffectText(row.proto, level > 0 ? level : Math.max(nextLevel, 1)));
|
||||
const parts = [];
|
||||
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
|
||||
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();
|
||||
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
|
||||
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);
|
||||
row.$cost.text(parts.join(" · "));
|
||||
row.$upgrade
|
||||
.text(`Upgrade €${groupDigits(Math.round(cost))}`)
|
||||
.prop("disabled", !!requirement || budget < cost);
|
||||
.text(`Upgrade ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))}`)
|
||||
.prop("disabled", !!requirement);
|
||||
this._setBuildTooltip(row.$upgrade, row.proto, level, nextLevel + 1, region);
|
||||
if (row.$free) {
|
||||
// Free does not ignore placement rules (a port still needs the sea).
|
||||
@@ -332,7 +420,7 @@ export class CityModal {
|
||||
}
|
||||
if (level > 0) {
|
||||
row.$downgrade
|
||||
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
|
||||
.text(`Downgrade +${currencySymbol()}${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
|
||||
.prop("disabled", false);
|
||||
} else {
|
||||
row.$downgrade.text("Not built").prop("disabled", true);
|
||||
|
||||
@@ -2,12 +2,36 @@
|
||||
|
||||
import { groupDigits } from "../../../shared/text_format.js";
|
||||
import { policyById } from "../../../shared/data.js";
|
||||
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
// The world market's current prices, from the snapshot's market listing.
|
||||
export function marketPricesFrom(snapshot) {
|
||||
const prices = {};
|
||||
for (const entry of (snapshot && snapshot.resourceMarket) || []) {
|
||||
prices[entry.id] = entry.price;
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
// A fixed money cost quoted at today's market: a cost with no material bill of
|
||||
// its own is scaled by the global price index, so every euro figure drifts with
|
||||
// commodity prices.
|
||||
export function quotedMoney(amount, prices) {
|
||||
return Math.round((amount || 0) * marketPriceIndex(prices));
|
||||
}
|
||||
|
||||
// A construction's full price at today's market: the fixed build cost plus the
|
||||
// market value of its whole material bill, ignoring anything already in store.
|
||||
export function quotedBuildCost(buildCost, materials, prices) {
|
||||
return Math.round((buildCost || 0) + resourceMarketValue(materials, prices));
|
||||
}
|
||||
|
||||
export function formatEffect(effect, level) {
|
||||
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
|
||||
switch (effect.stat) {
|
||||
case "gdp":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP/capita`;
|
||||
case "production":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`;
|
||||
case "budget_income":
|
||||
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% tax income`;
|
||||
case "research":
|
||||
@@ -49,9 +73,12 @@ export function buildingEffectText(proto, level) {
|
||||
}
|
||||
case "air_defense":
|
||||
return "Fires one volley at hostile aircraft over the city";
|
||||
case "port_gdp": {
|
||||
const per = (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
|
||||
return `+${groupDigits(per)} GDP/capita per adjacent sea tile to the region`;
|
||||
case "port_production": {
|
||||
const per = (proto.productionPerSeaTilePerLevel || 0) * level;
|
||||
const food = (proto.foodPerSeaTilePerLevel || 0) * level * 100;
|
||||
const parts = [`+${groupDigits(per)} production per person per adjacent sea tile to the region`];
|
||||
if (food > 0) parts.push(`+${food.toFixed(1)}% food output per adjacent sea tile`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
case "air_immigration":
|
||||
return "Immigration from every other city with an airport";
|
||||
@@ -63,7 +90,12 @@ export function buildingEffectText(proto, level) {
|
||||
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
|
||||
|
||||
export function signedMoney(value) {
|
||||
return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`;
|
||||
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}/h`;
|
||||
}
|
||||
|
||||
// A signed money amount with no period suffix, for the monthly budget report.
|
||||
export function money(value) {
|
||||
return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}`;
|
||||
}
|
||||
|
||||
export function percent(value) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
governmentChangeCost,
|
||||
prerequisitesMet,
|
||||
} from "../../../shared/rules.js";
|
||||
import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost } from "../../../shared/data.js";
|
||||
import { renderBudget } from "./budget.js";
|
||||
import { effectsText, signedMoney } from "./format.js";
|
||||
import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost, RESOURCES } from "../../../shared/data.js";
|
||||
import { formatResourceAmount } from "../../../shared/resources.js";
|
||||
import { renderMonthlyBudget } from "./budget.js";
|
||||
import { effectsText, money, quotedMoney } from "./format.js";
|
||||
import { bindTabs } from "./tabs.js";
|
||||
import { ethnicityChart } from "../ui/ethnicity.js";
|
||||
import { currencySymbol } from "../currency.js";
|
||||
|
||||
export class NationModal {
|
||||
constructor() {
|
||||
@@ -19,7 +21,25 @@ export class NationModal {
|
||||
this.$technologySub = $("#technology-sub");
|
||||
this.$technologyList = $("#technology-list");
|
||||
this.$budgetSub = $("#budget-sub");
|
||||
this.$budgetMonths = $("#budget-months");
|
||||
this.$budgetList = $("#budget-list");
|
||||
this.$economySub = $("#economy-sub");
|
||||
this.$economySubtabs = $("#economy-subtabs");
|
||||
this.$economySummary = $("#economy-summary");
|
||||
this.$economyCentralBank = $("#economy-central-bank");
|
||||
this.$economyRates = $("#economy-rates");
|
||||
// Which Economy sub-tab is open: the private sector, the central bank or
|
||||
// the exchange rates.
|
||||
this.economyView = "summary";
|
||||
// The central-bank interest input is built once and only its value updated,
|
||||
// so typing is not interrupted by the snapshot refresh.
|
||||
this._centralBankBuilt = false;
|
||||
this.$resourcesSub = $("#resources-sub");
|
||||
this.$resourcesList = $("#resources-list");
|
||||
this.$resourceTradeList = $("#resource-trade-list");
|
||||
// Which Resources sub-tab is open: the nation's own stores and trade, or
|
||||
// the world ranking of each commodity's major exporter and importer.
|
||||
this.resourceView = "stores";
|
||||
this.$diplomacySub = $("#diplomacy-sub");
|
||||
this.$diplomacyList = $("#diplomacy-list");
|
||||
this.$citiesSub = $("#cities-sub");
|
||||
@@ -43,9 +63,12 @@ export class NationModal {
|
||||
// Conflict cards the player expanded, keyed "a:b". Snapshot refreshes
|
||||
// rebuild the list, so the open/closed state has to live outside the DOM.
|
||||
this.expandedConflicts = new Set();
|
||||
// Building types whose per-city budget breakdown is expanded.
|
||||
this.expandedBudgetGroups = new Set();
|
||||
this._lastBreakdown = {};
|
||||
// How many months back the budget tab is paging: 0 is the current month.
|
||||
this.budgetOffset = 0;
|
||||
this._treasury = 0;
|
||||
this._budgetMonths = [];
|
||||
// Expense categories whose per-resource purchase detail is expanded.
|
||||
this.expandedBudgetCategories = new Set();
|
||||
this.politics = null;
|
||||
// The Politics tab is built once and only its lists are refreshed, so the
|
||||
// wizard and dropdowns survive the snapshots that arrive while it is open.
|
||||
@@ -61,6 +84,7 @@ export class NationModal {
|
||||
this.onSetCampaignSpending = () => {};
|
||||
this.onPolicy = () => {};
|
||||
this.onRevokePolicy = () => {};
|
||||
this.onSetInterestRate = () => {};
|
||||
this.onConfirm = () => {};
|
||||
this.$modal.find(".modal-close").on("click", () => this.close());
|
||||
bindTabs(this.$modal, "tab-");
|
||||
@@ -70,12 +94,32 @@ export class NationModal {
|
||||
return !this.$modal.hasClass("hidden");
|
||||
}
|
||||
|
||||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null) {
|
||||
// Today's world market prices, from the Resources tab's data. Used to quote
|
||||
// the money costs that carry no material bill of their own.
|
||||
_prices() {
|
||||
if (this.resources && this.resources.summary && this.resources.summary.prices) {
|
||||
return this.resources.summary.prices;
|
||||
}
|
||||
const prices = {};
|
||||
for (const entry of (this.resources && this.resources.market) || []) {
|
||||
prices[entry.id] = entry.price;
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
// A fixed money cost quoted at today's market.
|
||||
_quoted(amount) {
|
||||
return quotedMoney(amount, this._prices());
|
||||
}
|
||||
|
||||
show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||||
this.governments = governments;
|
||||
this.technologies = technologies;
|
||||
this.diplomacy = diplomacy;
|
||||
this.conflicts = conflicts || [];
|
||||
this.politics = politics;
|
||||
this.resources = resourcesInfo;
|
||||
this.money = moneyInfo;
|
||||
// Rebuild the rows from scratch: reopening the panel must not stack a
|
||||
// second set of buttons on top of the previous one, or the stale rows keep
|
||||
// their old labels and disabled state and appear unresponsive.
|
||||
@@ -84,7 +128,9 @@ export class NationModal {
|
||||
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
|
||||
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
|
||||
this._buildTechnologyThemes();
|
||||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo);
|
||||
this._buildResourceSubtabs();
|
||||
this._buildEconomySubtabs();
|
||||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
|
||||
this.$modal.removeClass("hidden");
|
||||
}
|
||||
|
||||
@@ -113,18 +159,282 @@ export class NationModal {
|
||||
}
|
||||
}
|
||||
|
||||
// The national budget, drawn by the shared table renderer. Building types
|
||||
// expand to their per-city cost.
|
||||
_renderBudget(breakdown) {
|
||||
// The Resources tab's sub-tabs: the nation's own stores and trade, and the
|
||||
// world ranking of each commodity's major exporters and importers. Both views
|
||||
// are rendered together; switching only shows and hides them.
|
||||
_buildResourceSubtabs() {
|
||||
const $tabs = $("#resources-subtabs");
|
||||
if (!$tabs.length) return;
|
||||
$tabs.empty();
|
||||
const views = [
|
||||
{ id: "stores", label: "Stores & trade" },
|
||||
{ id: "leaders", label: "Major traders" },
|
||||
];
|
||||
for (const view of views) {
|
||||
const $button = $("<button type='button' class='subtab'></button>")
|
||||
.attr("data-view", view.id)
|
||||
.text(view.label);
|
||||
if (view.id === this.resourceView) $button.addClass("active");
|
||||
$button.on("click", () => this._setResourceView(view.id));
|
||||
$tabs.append($button);
|
||||
}
|
||||
}
|
||||
|
||||
_setResourceView(view) {
|
||||
this.resourceView = view;
|
||||
const $tabs = $("#resources-subtabs");
|
||||
$tabs.find(".subtab").removeClass("active");
|
||||
$tabs.find(`.subtab[data-view="${view}"]`).addClass("active");
|
||||
if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores");
|
||||
if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders");
|
||||
}
|
||||
|
||||
// The Economy tab's sub-tabs: the private sector, the central bank and the
|
||||
// exchange-rate table.
|
||||
_buildEconomySubtabs() {
|
||||
if (!this.$economySubtabs || !this.$economySubtabs.length) return;
|
||||
this.$economySubtabs.empty();
|
||||
const views = [
|
||||
{ id: "summary", label: "Private sector" },
|
||||
{ id: "centralbank", label: "Central bank" },
|
||||
{ id: "rates", label: "Exchange rates" },
|
||||
];
|
||||
for (const view of views) {
|
||||
const $button = $("<button type='button' class='subtab'></button>")
|
||||
.attr("data-view", view.id)
|
||||
.text(view.label);
|
||||
if (view.id === this.economyView) $button.addClass("active");
|
||||
$button.on("click", () => this._setEconomyView(view.id));
|
||||
this.$economySubtabs.append($button);
|
||||
}
|
||||
this._setEconomyView(this.economyView);
|
||||
}
|
||||
|
||||
_setEconomyView(view) {
|
||||
this.economyView = view;
|
||||
if (this.$economySubtabs) {
|
||||
this.$economySubtabs.find(".subtab").removeClass("active");
|
||||
this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active");
|
||||
}
|
||||
if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary");
|
||||
if (this.$economyCentralBank) {
|
||||
this.$economyCentralBank.toggleClass("hidden", view !== "centralbank");
|
||||
}
|
||||
if (this.$economyRates) this.$economyRates.toggleClass("hidden", view !== "rates");
|
||||
// Render the view that just became visible.
|
||||
this._renderEconomy(this.money);
|
||||
}
|
||||
|
||||
_renderEconomy(money) {
|
||||
this.money = money || this.money || {};
|
||||
if (this.economyView === "centralbank") this._renderCentralBank(this.money);
|
||||
else if (this.economyView === "rates") this._renderExchangeRates(this.money);
|
||||
else this._renderPrivateSector(this.money);
|
||||
}
|
||||
|
||||
// The aggregate private-sector cash the nation's regions hold, and each
|
||||
// region's own reserve.
|
||||
_renderPrivateSector(money) {
|
||||
if (!this.$economySummary || !this.$economySummary.length) return;
|
||||
const cash = money.privateCash || 0;
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
this.$economySub.text(
|
||||
`Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` +
|
||||
`${(money.currency && money.currency.name) || ""}`
|
||||
);
|
||||
}
|
||||
this.$economySummary.empty();
|
||||
this.$economySummary.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"Regions are private economic agents: each holds its own cash reserve, " +
|
||||
"trades with its neighbours and chases profit."
|
||||
)
|
||||
);
|
||||
const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv);
|
||||
if (cities.length === 0) return;
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $head = $("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Region"))
|
||||
.append($("<th class='numeric'></th>").text("Private cash"))
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
let total = 0;
|
||||
for (const city of cities) {
|
||||
total += city.cashValue || 0;
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(city.name))
|
||||
.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
const $foot = $("<tfoot></tfoot>").append(
|
||||
$("<tr class='budget-total'></tr>")
|
||||
.append($("<td></td>").text("Total"))
|
||||
.append($("<td class='numeric'></td>").text(
|
||||
`${currencySymbol()}${groupDigits(Math.round(total))}`
|
||||
))
|
||||
);
|
||||
$table.append($foot);
|
||||
this.$economySummary.append($table);
|
||||
}
|
||||
|
||||
// The central bank: its interest rate, which compounds into every region's
|
||||
// private cash, and the foreign reserves it has accumulated.
|
||||
_renderCentralBank(money) {
|
||||
if (!this.$economyCentralBank || !this.$economyCentralBank.length) return;
|
||||
const bank = money.centralBank || { rate: 0, reserves: [] };
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
this.$economySub.text(
|
||||
`${(money.currency && money.currency.name) || "Central bank"} · ` +
|
||||
`Interest rate ${(bank.rate * 100).toFixed(2)}%/year`
|
||||
);
|
||||
}
|
||||
if (!this._centralBankBuilt) {
|
||||
this.$economyCentralBank.empty();
|
||||
const $rate = $("<div class='central-bank-rate'></div>");
|
||||
$rate.append($("<label for='cb-rate'></label>").text("Interest rate (% per year)"));
|
||||
const $input = $("<input id='cb-rate' type='number' min='0' max='50' step='0.25'>");
|
||||
const $apply = $("<button type='button' class='aero cb-apply'></button>").text("Set");
|
||||
const apply = () => {
|
||||
const percent = Number($input.val());
|
||||
if (!Number.isFinite(percent) || percent < 0) return;
|
||||
this.onSetInterestRate(percent / 100);
|
||||
};
|
||||
$apply.on("click", apply);
|
||||
$input.on("keydown", (event) => {
|
||||
if (event.key === "Enter") apply();
|
||||
});
|
||||
$rate.append($input, $apply);
|
||||
this.$economyCentralBank.append($rate);
|
||||
this.$economyCentralBank.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"Regions borrow from the bank at this rate when their cash runs out, " +
|
||||
"and it compounds on whatever they owe. The government may run an " +
|
||||
"overdraft here too."
|
||||
)
|
||||
);
|
||||
this.$economyCentralBank.append($("<div class='central-bank-loans'></div>"));
|
||||
const $reserves = $("<div class='central-bank-reserves'></div>");
|
||||
this.$economyCentralBank.append($reserves);
|
||||
this._centralBankBuilt = true;
|
||||
}
|
||||
const $input = this.$economyCentralBank.find("#cb-rate");
|
||||
if ($input.length && document.activeElement !== $input.get(0)) {
|
||||
$input.val((bank.rate * 100).toFixed(2));
|
||||
}
|
||||
this.$economyCentralBank.find(".central-bank-loans").text(
|
||||
`Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}`
|
||||
);
|
||||
const $reserves = this.$economyCentralBank.find(".central-bank-reserves");
|
||||
$reserves.empty();
|
||||
$reserves.append($("<div class='subhead'></div>").text("Foreign reserves"));
|
||||
if ((bank.reserves || []).length === 0) {
|
||||
$reserves.append($("<div class='desc'></div>").text("No foreign currency held yet."));
|
||||
return;
|
||||
}
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const reserve of bank.reserves) {
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(`${reserve.symbol} ${reserve.name}`))
|
||||
.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
groupDigits(Math.round(reserve.amount || 0))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
$table.append($body);
|
||||
$reserves.append($table);
|
||||
}
|
||||
|
||||
// The exchange rate between the viewer's currency and every other.
|
||||
_renderExchangeRates(money) {
|
||||
if (!this.$economyRates || !this.$economyRates.length) return;
|
||||
const mine = money.currency || { name: "Currency" };
|
||||
if (this.$economySub && this.$economySub.length) {
|
||||
this.$economySub.text(`Exchange rates against the ${mine.name}`);
|
||||
}
|
||||
this.$economyRates.empty();
|
||||
const rows = money.exchangeRates || [];
|
||||
const $table = $("<table class='resources-table economy-table'></table>");
|
||||
const $head = $("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Currency"))
|
||||
.append($("<th class='numeric'></th>").text(`1 ${mine.code || ""} buys`))
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const entry of rows) {
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append($("<td></td>").text(`${entry.symbol} ${entry.name} (${entry.code})`))
|
||||
.append($("<td class='numeric'></td>").text(entry.rate.toFixed(4)))
|
||||
);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$economyRates.append($table);
|
||||
}
|
||||
|
||||
// The national budget: the selected month's actual income and expenses, with
|
||||
// a pager back through the stored monthly digests. The treasury stays on the
|
||||
// subtitle so the table is purely the month's flows.
|
||||
_renderBudget(finances) {
|
||||
if (!this.$budgetList || !this.$budgetList.length) return;
|
||||
this._lastBreakdown = breakdown || {};
|
||||
renderBudget(this.$budgetList, this._lastBreakdown, {
|
||||
expanded: this.expandedBudgetGroups,
|
||||
onToggle: () => this._renderBudget(this._lastBreakdown),
|
||||
showUnits: true,
|
||||
const months = (finances && finances.months) || [];
|
||||
this._budgetMonths = months;
|
||||
const maxOffset = Math.max(0, months.length - 1);
|
||||
if (this.budgetOffset > maxOffset) this.budgetOffset = maxOffset;
|
||||
if (this.budgetOffset < 0) this.budgetOffset = 0;
|
||||
const index = months.length - 1 - this.budgetOffset;
|
||||
const report = index >= 0 ? months[index] : null;
|
||||
this._renderBudgetMonthsNav(months, index);
|
||||
if (this.$budgetSub && this.$budgetSub.length) {
|
||||
const net = report ? ` · ${report.label}: ${money(report.net)}` : "";
|
||||
this.$budgetSub.text(`Treasury: ${currencySymbol()}${groupDigits(Math.round(this._treasury))}${net}`);
|
||||
}
|
||||
renderMonthlyBudget(this.$budgetList, report, {
|
||||
expanded: this.expandedBudgetCategories,
|
||||
onToggle: () => this._renderBudget({ months }),
|
||||
});
|
||||
}
|
||||
|
||||
_renderBudgetMonthsNav(months, index) {
|
||||
if (!this.$budgetMonths || !this.$budgetMonths.length) return;
|
||||
this.$budgetMonths.empty();
|
||||
if (months.length === 0) return;
|
||||
const older = this.budgetOffset < months.length - 1;
|
||||
const newer = this.budgetOffset > 0;
|
||||
const $prev = $("<button type='button' class='budget-month-nav'></button>")
|
||||
.text("◀")
|
||||
.prop("disabled", !older)
|
||||
.on("click", () => {
|
||||
if (!older) return;
|
||||
this.budgetOffset += 1;
|
||||
this._renderBudget({ months });
|
||||
});
|
||||
const $next = $("<button type='button' class='budget-month-nav'></button>")
|
||||
.text("▶")
|
||||
.prop("disabled", !newer)
|
||||
.on("click", () => {
|
||||
if (!newer) return;
|
||||
this.budgetOffset -= 1;
|
||||
this._renderBudget({ months });
|
||||
});
|
||||
const label = months[index] ? months[index].label : "";
|
||||
this.$budgetMonths.append(
|
||||
$prev,
|
||||
$("<span class='budget-month-label'></span>").text(label),
|
||||
$next
|
||||
);
|
||||
}
|
||||
|
||||
// The nation's regions: each city's population, GDP and the tax it collects.
|
||||
_renderCities(cities) {
|
||||
if (!this.$citiesList || !this.$citiesList.length) return;
|
||||
@@ -170,8 +480,8 @@ export class NationModal {
|
||||
const $name = $("<td class='name'></td>").text(city.isCapital ? `★ ${city.name}` : city.name);
|
||||
$tr.append($name);
|
||||
$tr.append($("<td class='numeric'></td>").text(compact(city.population)));
|
||||
$tr.append($("<td class='numeric'></td>").text(`€${compact(city.gdp)}`));
|
||||
$tr.append($("<td class='numeric income'></td>").text(`+€${compact(city.income)}`));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(city.gdp)}`));
|
||||
$tr.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(city.income)}`));
|
||||
$body.append($tr);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
@@ -180,13 +490,201 @@ export class NationModal {
|
||||
$("<tr class='budget-total'></tr>")
|
||||
.append($("<td></td>").text("TOTAL"))
|
||||
.append($("<td class='numeric'></td>").text(compact(totalPopulation)))
|
||||
.append($("<td class='numeric'></td>").text(`€${compact(totalGdp)}`))
|
||||
.append($("<td class='numeric income'></td>").text(`+€${compact(totalIncome)}`))
|
||||
.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(totalGdp)}`))
|
||||
.append($("<td class='numeric income'></td>").text(`+${currencySymbol()}${compact(totalIncome)}`))
|
||||
)
|
||||
);
|
||||
this.$citiesList.append($table);
|
||||
}
|
||||
|
||||
// The Resources tab: the nation's stores, what it consumes and makes, what it
|
||||
// imports and exports, beside the global market's price and the world's supply
|
||||
// and demand. Energy is a daily flow, so it has no store: its row reads the
|
||||
// cities' demand against the power plants' output instead.
|
||||
_renderResources(info) {
|
||||
if (!this.$resourcesList || !this.$resourcesList.length) return;
|
||||
this.$resourcesList.empty();
|
||||
const summary = info && info.summary;
|
||||
if (this.$resourcesSub && this.$resourcesSub.length) {
|
||||
this.$resourcesSub.text(
|
||||
"Stores, daily consumption, production and cross-nation trade, with the " +
|
||||
"global market's price. Energy is delivered as a flow and cannot be stored."
|
||||
);
|
||||
}
|
||||
if (!summary) {
|
||||
this.$resourcesList.append($("<div class='desc'></div>").text("No resource data."));
|
||||
} else {
|
||||
this._renderResourceStores(info, summary);
|
||||
}
|
||||
this._renderResourceLeaders(info);
|
||||
this._setResourceView(this.resourceView);
|
||||
}
|
||||
|
||||
_renderResourceStores(info, summary) {
|
||||
const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
|
||||
const trade = summary.trade || { imports: {}, exports: {} };
|
||||
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
|
||||
const columns = [
|
||||
{ label: "Resource" },
|
||||
{ label: "Stored", numeric: true },
|
||||
{ label: "Consumed / day", numeric: true },
|
||||
{ label: "Made / day", numeric: true },
|
||||
{ label: "Exported / day", numeric: true },
|
||||
{ label: "Imported / day", numeric: true },
|
||||
{ label: "Market price", numeric: true },
|
||||
{ label: "World supply / day", numeric: true },
|
||||
{ label: "World demand / day", numeric: true },
|
||||
];
|
||||
const $head = $("<thead></thead>");
|
||||
const $headRow = $("<tr></tr>");
|
||||
for (const column of columns) {
|
||||
const $th = $("<th></th>").text(column.label);
|
||||
if (column.numeric) $th.addClass("numeric");
|
||||
$headRow.append($th);
|
||||
}
|
||||
$head.append($headRow);
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const proto of RESOURCES) {
|
||||
const energy = proto.id === "energy";
|
||||
const stats = market.get(proto.id) || {};
|
||||
const stored = energy ? "—" : formatResourceAmount(proto.id, summary.stock[proto.id] || 0);
|
||||
const consumed = energy
|
||||
? formatResourceAmount("energy", (summary.consumed && summary.consumed.energy) || 0)
|
||||
: formatResourceAmount(proto.id, (summary.consumed && summary.consumed[proto.id]) || 0);
|
||||
const made = energy
|
||||
? formatResourceAmount("energy", summary.energy ? summary.energy.supply : 0)
|
||||
: formatResourceAmount(proto.id, summary.production[proto.id] || 0);
|
||||
const exported = formatResourceAmount(
|
||||
proto.id,
|
||||
(trade.exports && trade.exports[proto.id]) || 0
|
||||
);
|
||||
const imported = formatResourceAmount(
|
||||
proto.id,
|
||||
(trade.imports && trade.imports[proto.id]) || 0
|
||||
);
|
||||
const $tr = $("<tr></tr>");
|
||||
const $name = $("<td class='name'></td>");
|
||||
$name.append(
|
||||
$("<img class='resource-icon' width='18' height='18'>")
|
||||
.attr("src", `assets/${proto.icon}`)
|
||||
.attr("alt", proto.name)
|
||||
);
|
||||
$name.append($("<span></span>").text(` ${proto.name}`));
|
||||
$tr.append($name);
|
||||
$tr.append($("<td class='numeric'></td>").text(stored));
|
||||
$tr.append($("<td class='numeric'></td>").text(consumed));
|
||||
$tr.append($("<td class='numeric'></td>").text(made));
|
||||
$tr.append($("<td class='numeric'></td>").text(exported));
|
||||
$tr.append($("<td class='numeric'></td>").text(imported));
|
||||
$tr.append($("<td class='numeric'></td>").text(this._resourcePriceText(proto, stats.price)));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
formatResourceAmount(proto.id, Math.max(0, stats.supply || 0))
|
||||
)
|
||||
);
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
formatResourceAmount(proto.id, Math.max(0, stats.demand || 0))
|
||||
)
|
||||
);
|
||||
$body.append($tr);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$resourcesList.append($table);
|
||||
this.$resourcesList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"Consumption is the people's needs plus the materials buildings, units and works " +
|
||||
"wear out. Exports and imports count yesterday's deliveries between nations; the " +
|
||||
"global market is not a partner. Prices drift with the world's supply and demand, " +
|
||||
"and a city buys from the cheapest source it can reach, then from the market."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// The Major traders sub-tab: for every commodity, the nation that exported the
|
||||
// most over the last day and the one that imported the most.
|
||||
_renderResourceLeaders(info) {
|
||||
if (!this.$resourceTradeList || !this.$resourceTradeList.length) return;
|
||||
this.$resourceTradeList.empty();
|
||||
const trade = (info && info.trade) || [];
|
||||
const civs = (info && info.civs) || [];
|
||||
const name = (civ) => (civs[civ] ? civs[civ].name : `Nation ${civ + 1}`);
|
||||
const $table = $("<table class='diplomacy-table budget-table resources-table'></table>");
|
||||
const columns = [
|
||||
{ label: "Resource" },
|
||||
{ label: "Major exporter" },
|
||||
{ label: "Exported / day", numeric: true },
|
||||
{ label: "Major importer" },
|
||||
{ label: "Imported / day", numeric: true },
|
||||
];
|
||||
const $head = $("<thead></thead>");
|
||||
const $headRow = $("<tr></tr>");
|
||||
for (const column of columns) {
|
||||
const $th = $("<th></th>").text(column.label);
|
||||
if (column.numeric) $th.addClass("numeric");
|
||||
$headRow.append($th);
|
||||
}
|
||||
$head.append($headRow);
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const proto of RESOURCES) {
|
||||
const best = (kind) => {
|
||||
let winner = null;
|
||||
for (const entry of trade) {
|
||||
const amount = (entry[kind] && entry[kind][proto.id]) || 0;
|
||||
if (amount > 0 && (!winner || amount > winner.amount)) {
|
||||
winner = { civ: entry.civ, amount };
|
||||
}
|
||||
}
|
||||
return winner;
|
||||
};
|
||||
const exporter = best("exports");
|
||||
const importer = best("imports");
|
||||
const $tr = $("<tr></tr>");
|
||||
const $name = $("<td class='name'></td>");
|
||||
$name.append(
|
||||
$("<img class='resource-icon' width='18' height='18'>")
|
||||
.attr("src", `assets/${proto.icon}`)
|
||||
.attr("alt", proto.name)
|
||||
);
|
||||
$name.append($("<span></span>").text(` ${proto.name}`));
|
||||
$tr.append($name);
|
||||
$tr.append($("<td></td>").text(exporter ? name(exporter.civ) : "—"));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
exporter ? formatResourceAmount(proto.id, exporter.amount) : "—"
|
||||
)
|
||||
);
|
||||
$tr.append($("<td></td>").text(importer ? name(importer.civ) : "—"));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").text(
|
||||
importer ? formatResourceAmount(proto.id, importer.amount) : "—"
|
||||
)
|
||||
);
|
||||
$body.append($tr);
|
||||
}
|
||||
$table.append($head, $body);
|
||||
this.$resourceTradeList.append($table);
|
||||
this.$resourceTradeList.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"The largest cross-nation deliveries of the last day, in each resource. " +
|
||||
"Only trade between nations is counted, so goods bought from the global " +
|
||||
"market never make a nation an importer here."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// A market price reads in euro per the resource's own unit; tiny energy prices
|
||||
// keep enough decimals to be legible.
|
||||
_resourcePriceText(proto, price) {
|
||||
const value = price === undefined || price === null ? 0 : price;
|
||||
const text = value >= 1000
|
||||
? groupDigits(Math.round(value))
|
||||
: value >= 1
|
||||
? value.toFixed(0)
|
||||
: value.toFixed(4);
|
||||
return `${currencySymbol()}${text} / ${proto.unit}`;
|
||||
}
|
||||
|
||||
_makeRow($list, proto, index, kind) {
|
||||
const $card = $("<div class='row-card'></div>");
|
||||
const $info = $("<div class='info'></div>");
|
||||
@@ -207,29 +705,29 @@ export class NationModal {
|
||||
return { proto, index, kind, $name, $effects, $cost, $action, $card };
|
||||
}
|
||||
|
||||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null) {
|
||||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||||
this.currentGovernment = currentGovernment;
|
||||
if (techInfo) this.techInfo = techInfo;
|
||||
if (resourcesInfo) this.resources = resourcesInfo;
|
||||
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
|
||||
if (diplomacy !== null) this.diplomacy = diplomacy;
|
||||
if (conflicts !== null) this.conflicts = conflicts || [];
|
||||
if (politics !== null) this.politics = politics;
|
||||
this.$governmentSub.text(
|
||||
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
|
||||
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`
|
||||
);
|
||||
// Research has no pool: every point flows into the focused technology, so
|
||||
// the panel shows the rate instead of a stockpile.
|
||||
this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`);
|
||||
|
||||
const breakdown = finances || {};
|
||||
const net = breakdown.net || 0;
|
||||
const fin = finances || {};
|
||||
this._treasury = budget;
|
||||
|
||||
this.$budgetSub.text(
|
||||
`Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
|
||||
);
|
||||
|
||||
this._renderBudget(breakdown);
|
||||
this._renderCities(breakdown.cities);
|
||||
if (moneyInfo) this.money = moneyInfo;
|
||||
this._renderBudget(fin);
|
||||
this._renderCities(fin.cities);
|
||||
this._renderResources(this.resources);
|
||||
this._renderEconomy(this.money);
|
||||
|
||||
for (const row of this.governmentRows) {
|
||||
const proto = row.proto;
|
||||
@@ -240,8 +738,10 @@ export class NationModal {
|
||||
row.$action.text("Current government").prop("disabled", true);
|
||||
} else {
|
||||
const cost = governmentChangeCost(proto, gdp);
|
||||
row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`);
|
||||
row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
|
||||
row.$cost.text(`Adoption cost ${currencySymbol()}${groupDigits(this._quoted(cost))}`);
|
||||
row.$action
|
||||
.text(`Adopt ${currencySymbol()}${groupDigits(this._quoted(cost))}`)
|
||||
.prop("disabled", false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +870,8 @@ export class NationModal {
|
||||
const $tr = $("<tr></tr>");
|
||||
if (row.self) $tr.addClass("local-nation");
|
||||
$tr.append($("<td class='name'></td>").text(row.name));
|
||||
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdp)}`));
|
||||
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdpPerCapita)}`));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdp)}`));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdpPerCapita)}`));
|
||||
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${Math.round(row.approval * 100)}%`));
|
||||
const $status = $("<td class='status'></td>");
|
||||
@@ -604,7 +1104,7 @@ export class NationModal {
|
||||
const spent = hourly * activeHours;
|
||||
const days = Math.floor(activeHours / 24);
|
||||
return (
|
||||
`€${groupDigits(hourly)}/h · €${groupDigits(Math.round(spent))} spent over ` +
|
||||
`${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
|
||||
`${days} day${days === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
@@ -632,10 +1132,10 @@ export class NationModal {
|
||||
value: campaign.hourlyCost,
|
||||
});
|
||||
const $spend = $("<span class='politics-readout'></span>").text(
|
||||
`€${groupDigits(campaign.hourlyCost)}/h`
|
||||
`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
|
||||
);
|
||||
$slider.on("input", () =>
|
||||
$spend.text(`€${groupDigits(Number($slider.val()))}/h`)
|
||||
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
|
||||
);
|
||||
$slider.on("change", () => {
|
||||
const amount = Number($slider.val());
|
||||
@@ -643,10 +1143,10 @@ export class NationModal {
|
||||
// Reverting to the last known spend keeps the control honest until
|
||||
// the player confirms the stop.
|
||||
$slider.val(campaign.hourlyCost);
|
||||
$spend.text(`€${groupDigits(campaign.hourlyCost)}/h`);
|
||||
$spend.text(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
|
||||
this.onConfirm(
|
||||
"Stop this campaign?",
|
||||
"Cutting the spending to €0 will stop the campaign.",
|
||||
`Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
|
||||
() => this.onCancelCampaign(campaign.id)
|
||||
);
|
||||
} else {
|
||||
@@ -683,7 +1183,7 @@ export class NationModal {
|
||||
);
|
||||
$card.append(
|
||||
$("<div class='desc politics-cost'></div>").text(
|
||||
`Costs €${groupDigits(PROPAGANDA.defaultHourlyCost)}/h to begin; ` +
|
||||
`Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
|
||||
"adjust the spending on an active campaign to make it land harder or softer."
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Headless resource-economy simulation, used to tune the costs in
|
||||
// shared/data/resources.js. It boots a game, optionally raises a fixed set of
|
||||
// resource buildings for every nation, runs a number of in-game days and prints
|
||||
// how the economy settles: what each nation spends on power and goods, whether
|
||||
// its people are fed, and how each producer's build cost pays back at market
|
||||
// prices.
|
||||
//
|
||||
// node scripts/simulate_resources.js
|
||||
// node scripts/simulate_resources.js --days 720 --civs 2 --scenario infra
|
||||
// node scripts/simulate_resources.js --map full --days 365 --civs 6
|
||||
|
||||
import { GameState } from "../shared/game_state.js";
|
||||
import { CIVILISATIONS, MAP_CONFIG } from "../shared/data.js";
|
||||
import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
|
||||
import {
|
||||
cityEnergyPerDay,
|
||||
foodNeedPerDay,
|
||||
steelNeedPerDay,
|
||||
luxuryNeedPerDay,
|
||||
} from "../shared/resources.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const DAYS = Number(args.days || 360);
|
||||
const CIVS = Number(args.civs || 2);
|
||||
const SCENARIO = args.scenario || "baseline";
|
||||
const MAP = args.map || "medium";
|
||||
const seed = Number(args.seed || 12345);
|
||||
|
||||
const state = new GameState();
|
||||
state.mapConfig = mapConfig(MAP);
|
||||
const civs = CIVILISATIONS.slice(0, Math.max(1, Math.min(CIVS, CIVILISATIONS.length)));
|
||||
state.configure(civs, seed);
|
||||
|
||||
if (SCENARIO === "infra") seedInfrastructure(state);
|
||||
if (SCENARIO === "developed") seedBuildings(state);
|
||||
|
||||
const startBudget = civs.map((_, civ) => state.getBudget(civ));
|
||||
const spendTotals = civs.map(() => ({ energy: 0, goods: 0, transport: 0, construction: 0, upkeep: 0, combat: 0 }));
|
||||
|
||||
for (let day = 0; day < DAYS; day++) {
|
||||
for (let hour = 0; hour < 24; hour++) state.advanceHour();
|
||||
for (let civ = 0; civ < civs.length; civ++) {
|
||||
const record = state.resourceSpend.get(civ) || {};
|
||||
for (const kind of Object.keys(spendTotals[civ])) {
|
||||
spendTotals[civ][kind] += record[kind] || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(list) {
|
||||
const out = {};
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i];
|
||||
if (!item.startsWith("--")) continue;
|
||||
const name = item.slice(2);
|
||||
const next = list[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) out[name] = true;
|
||||
else {
|
||||
out[name] = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapConfig(kind) {
|
||||
if (kind === "full") return MAP_CONFIG;
|
||||
return {
|
||||
...MAP_CONFIG,
|
||||
mapSize: { x: 48, y: 40 },
|
||||
minCapitalLandmass: 8,
|
||||
minCapitalDistance: 4,
|
||||
citiesPerCiv: 4,
|
||||
citySpacing: 3,
|
||||
minContinents: 2,
|
||||
smallIslandCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// A representative national grid: one coal plant per city, one steel mill, one
|
||||
// chip foundry and a gold mine, all dropped on free land so the steady state
|
||||
// can be read without waiting for construction.
|
||||
function seedInfrastructure(state) {
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
const plan = [
|
||||
"coal_power_plant",
|
||||
"coal_power_plant",
|
||||
"coal_power_plant",
|
||||
"coal_power_plant",
|
||||
"coal_power_plant",
|
||||
"steel_mill",
|
||||
"chip_foundry",
|
||||
"gold_mine",
|
||||
];
|
||||
const tiles = (state._territoryByCiv.get(civ) || []).filter(
|
||||
(coords) => state._isLand(coords) && !state.cityAt(coords)
|
||||
);
|
||||
let used = 0;
|
||||
for (const id of plan) {
|
||||
if (used >= tiles.length) break;
|
||||
const coords = tiles[used++];
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, id);
|
||||
state.tileImprovementOwner.set(k, civ);
|
||||
state.tileImprovementHp.set(k, 1000);
|
||||
state._ensureResourceStock(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mature nation: every city carries a believable basket of buildings, so the
|
||||
// material upkeep (and repair bill) can be read.
|
||||
function seedBuildings(state) {
|
||||
const basket = {
|
||||
market: 3, bank: 2, barracks: 2, shelters: 1, aa_building: 1,
|
||||
research_lab: 2, university: 1, museum: 1, cinema_studio: 1, airport: 1,
|
||||
};
|
||||
for (const city of state.cities) {
|
||||
for (const [id, level] of Object.entries(basket)) {
|
||||
const index = state.protoBuildings.findIndex((b) => b.id === id);
|
||||
if (index < 0) continue;
|
||||
if (state.protoBuildings[index].coastal && !state.isCoastalCity(city)) continue;
|
||||
city.buildings[index] = level;
|
||||
}
|
||||
}
|
||||
state._gdpPerCapitaCache.clear();
|
||||
state._touchModifiers();
|
||||
}
|
||||
|
||||
function eur(value) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||
if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||
if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}k`;
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
function num(value) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}e9`;
|
||||
if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}e6`;
|
||||
if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}e3`;
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function pad(text, width, right = false) {
|
||||
const value = String(text);
|
||||
return right ? value.padStart(width) : value.padEnd(width);
|
||||
}
|
||||
|
||||
console.log(`\n=== Resource production costs (base market prices, ${DAYS} days) ===`);
|
||||
console.log(
|
||||
pad("building", 26) + pad("out/day", 12, true) + pad("energy/day", 13, true) +
|
||||
pad("energy €/d", 11, true) + pad("upkeep €/d", 11, true) + pad("gross €/d", 11, true) +
|
||||
pad("net €/d", 11, true) + pad("payback d", 10, true)
|
||||
);
|
||||
for (const building of RESOURCE_BUILDINGS) {
|
||||
const energyPrice = RESOURCE_MARKET_BASE.energy;
|
||||
const outputValue = building.resource === "energy"
|
||||
? building.outputPerDay * RESOURCE_MARKET_BASE.energy
|
||||
: building.outputPerDay * (RESOURCE_MARKET_BASE[building.resource] || 0);
|
||||
const energyCost = (building.energyPerDay || 0) * energyPrice;
|
||||
const upkeep = building.maintenance * 24;
|
||||
const net = outputValue - energyCost - upkeep;
|
||||
const payback = net > 0 ? building.buildCost / net : Infinity;
|
||||
console.log(
|
||||
pad(building.id, 26) + pad(num(building.outputPerDay), 12, true) +
|
||||
pad(num(building.energyPerDay || 0), 13, true) + pad(eur(energyCost), 11, true) +
|
||||
pad(eur(upkeep), 11, true) + pad(eur(outputValue), 11, true) +
|
||||
pad(eur(net), 11, true) + pad(Number.isFinite(payback) ? payback.toFixed(0) : "never", 10, true)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n=== Settled economy after ${DAYS} days (scenario: ${SCENARIO}, map: ${MAP}) ===`);
|
||||
for (let civ = 0; civ < civs.length; civ++) {
|
||||
const population = state.getPlayerPopulation(civ);
|
||||
const gdp = state.getPlayerGdp(civ);
|
||||
const income = state.getCivHourlyIncome(civ) * 24;
|
||||
const moneyUpkeep = state.getPlayerUpkeep(civ) * 24;
|
||||
const upkeepDemand = state.getCivUpkeepResources(civ);
|
||||
const spend = spendTotals[civ];
|
||||
let energyNeed = 0;
|
||||
let energySupply = 0;
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
energyNeed += cityEnergyPerDay(state.getCityEconomy(city).gdp);
|
||||
}
|
||||
for (const [k, id] of state.tileImprovements) {
|
||||
if (state.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = RESOURCE_BUILDINGS.find((b) => b.id === id);
|
||||
if (proto && proto.resource === "energy") energySupply += proto.outputPerDay;
|
||||
}
|
||||
const stock = state.getCivResourceSummary(civ);
|
||||
console.log(
|
||||
`\n-- ${civs[civ].name} --`
|
||||
);
|
||||
console.log(
|
||||
`pop ${num(population)} gdp ${eur(gdp)} gdp/cap ${eur(population ? gdp / population : 0)}` +
|
||||
` budget ${eur(state.getBudget(civ))} (start ${eur(startBudget[civ])})`
|
||||
);
|
||||
console.log(
|
||||
`income/d ${eur(income)} campaign upkeep/d ${eur(moneyUpkeep)} net/d ${eur(income - moneyUpkeep)}`
|
||||
);
|
||||
console.log(
|
||||
`material upkeep/d: steel ${num(upkeepDemand.steel)} t energy ${num(upkeepDemand.energy)} kWh` +
|
||||
` high-tech ${num(upkeepDemand.hightech)} u`
|
||||
);
|
||||
console.log(
|
||||
`resource spend over run: energy ${eur(spend.energy)} goods ${eur(spend.goods)}` +
|
||||
` transport ${eur(spend.transport)} construction ${eur(spend.construction)}` +
|
||||
` upkeep ${eur(spend.upkeep)} combat ${eur(spend.combat)}`
|
||||
);
|
||||
console.log(
|
||||
`energy need/d ${num(energyNeed)} kWh own supply/d ${num(energySupply)} kWh` +
|
||||
` bought-ish/d ${num((spend.energy / Math.max(DAYS, 1)) / Math.max(state.getResourcePrice("energy"), 1e-9))} kWh`
|
||||
);
|
||||
for (const id of ["food", "steel", "luxury", "hightech"]) {
|
||||
console.log(
|
||||
` ${pad(id, 9)} stock ${num(stock.stock[id])} need/d ${num(stock.need[id] || 0)}` +
|
||||
` produced/d ${num(stock.production[id] || 0)}`
|
||||
);
|
||||
}
|
||||
let shortage = 0;
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
const info = state.getCityResourceInfo(city);
|
||||
for (const value of Object.values(info.shortage)) shortage += value;
|
||||
}
|
||||
console.log(` summed city shortage fraction: ${shortage.toFixed(3)}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Market prices at the end ===");
|
||||
for (const id of Object.keys(RESOURCE_MARKET_BASE)) {
|
||||
const stats = state.resourceMarketStats.get(id) || {};
|
||||
console.log(
|
||||
` ${pad(id, 9)} price ${state.getResourcePrice(id).toFixed(4)}` +
|
||||
` (base ${RESOURCE_MARKET_BASE[id]}) supply ${num(stats.supply || 0)} demand ${num(stats.demand || 0)}`
|
||||
);
|
||||
}
|
||||
console.log(`\nRules: food ${RESOURCE_RULES.foodPerPersonPerDay} t/pp/d, steel ${RESOURCE_RULES.steelPerPersonPerDay} t/pp/d, luxury ${RESOURCE_RULES.luxuryPerPersonPerDay} ct/pp/d`);
|
||||
console.log(`energy intensity ${RESOURCE_RULES.gdpEnergyIntensity} kWh/€/yr, steel ${RESOURCE_RULES.energyPerSteelTonne / 1e6} GWh/t, food ${RESOURCE_RULES.energyPerFoodTonne / 1e6} GWh/t\n`);
|
||||
@@ -21,6 +21,7 @@ const DELTA_COLLECTIONS = [
|
||||
"tileEthnicity",
|
||||
"cityStats",
|
||||
"visible",
|
||||
"resourceGraph",
|
||||
];
|
||||
|
||||
// A wire move order carrying more units than this is split into chunks that
|
||||
@@ -216,6 +217,8 @@ export class GameServer extends EventEmitter {
|
||||
return this._handlePolicy(civ, order);
|
||||
case "revoke_policy":
|
||||
return this._handleRevokePolicy(civ, order);
|
||||
case "set_interest_rate":
|
||||
return this.state.requestSetInterestRate(civ, Number(order.rate));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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";
|
||||
import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
|
||||
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
|
||||
// the simulation and the UI can name them without magic strings.
|
||||
@@ -15,8 +15,8 @@ export const BUILDING_MECHANIC = {
|
||||
CIVILIAN_SHELTER: "civilian_shelter",
|
||||
// Fires one volley at hostile aircraft passing over the city.
|
||||
AIR_DEFENSE: "air_defense",
|
||||
// Flat GDP per capita for the city's region, per adjacent sea tile.
|
||||
PORT_GDP: "port_gdp",
|
||||
// Flat production per head for the city's region, per adjacent sea tile.
|
||||
PORT_PRODUCTION: "port_production",
|
||||
// Lets people fly in from any other city with an airport.
|
||||
AIR_IMMIGRATION: "air_immigration",
|
||||
};
|
||||
@@ -25,11 +25,11 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "market",
|
||||
name: "Market",
|
||||
description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.",
|
||||
description: "A hub of local trade. Raises the production of the city's region, with diminishing returns.",
|
||||
baseCost: 500_000,
|
||||
costAlpha: 1.2,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.04, 0.75)],
|
||||
icon: "icon_market.svg",
|
||||
},
|
||||
{
|
||||
@@ -70,6 +70,7 @@ export const BUILDINGS = [
|
||||
},
|
||||
{
|
||||
id: "aa_building",
|
||||
advanced: true,
|
||||
name: "Air defense",
|
||||
description: "Damages hostile aircraft that fly above this city.",
|
||||
baseCost: 1_000_000_000,
|
||||
@@ -77,10 +78,14 @@ export const BUILDINGS = [
|
||||
refundFraction: 0.5,
|
||||
effects: [],
|
||||
mechanic: "air_defense",
|
||||
// Cheap to keep ready; the cost is in the ammunition it fires (see the
|
||||
// combat consumption rules).
|
||||
materialUpkeep: { steel: 0.5, energy: 500, hightech: 0 },
|
||||
icon: "icon_aa_building.svg",
|
||||
},
|
||||
{
|
||||
id: "research_lab",
|
||||
advanced: true,
|
||||
name: "Research lab",
|
||||
description: "Applies science to industry and government. Produces research points for the whole nation.",
|
||||
baseCost: 100_000_000,
|
||||
@@ -91,6 +96,7 @@ export const BUILDINGS = [
|
||||
},
|
||||
{
|
||||
id: "university",
|
||||
advanced: true,
|
||||
name: "University",
|
||||
description: "Higher education produces research and a little culture for the whole nation.",
|
||||
baseCost: 100_000_000,
|
||||
@@ -122,26 +128,30 @@ export const BUILDINGS = [
|
||||
{
|
||||
id: "port",
|
||||
name: "Port",
|
||||
description: "Warm water port. Gains 1000 GDP/capita per tile per level.",
|
||||
description: "Warm water port. Adds 1000 production per person per adjacent sea tile per level, and brings in extra food.",
|
||||
baseCost: 20_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_BUDGET_INCOME, 0.08, 0.7)],
|
||||
// Every tile the city's region covers gains this much GDP per capita for
|
||||
// each sea tile next to the city, per port level.
|
||||
mechanic: "port_gdp",
|
||||
gdpPerCapitaPerSeaTilePerLevel: 1000,
|
||||
// Every person in the city's region produces this much more, for each sea
|
||||
// tile next to the city, per port level.
|
||||
mechanic: "port_production",
|
||||
productionPerSeaTilePerLevel: 1000,
|
||||
// The region also grows this much more food, as a fraction of its output,
|
||||
// for each sea tile beside the city, per port level.
|
||||
foodPerSeaTilePerLevel: 0.05,
|
||||
icon: "icon_port.svg",
|
||||
coastal: true,
|
||||
},
|
||||
{
|
||||
id: "airport",
|
||||
advanced: true,
|
||||
name: "Airport",
|
||||
description: "An airfield with a control tower. Lets the city train aircraft and serves as a home airbase for them to land and refuel. Immigration can reach this city from any other city in the world with an airport. The effect scales with airport level.",
|
||||
baseCost: 250_000_000,
|
||||
costAlpha: 1.7,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_GDP, 0.05, 0.8)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.05, 0.8)],
|
||||
mechanic: "air_immigration",
|
||||
icon: "icon_airport.svg",
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// The playable civilisations: colours, flag art and the pool of city names
|
||||
// drawn on when settlements are placed.
|
||||
// The playable civilisations: colours, flag art, national currency and the pool
|
||||
// of city names drawn on when settlements are placed.
|
||||
|
||||
export const CIVILISATIONS = [
|
||||
{
|
||||
id: "france",
|
||||
name: "France",
|
||||
demonym: "French",
|
||||
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
primaryColour: "#0d0d5c",
|
||||
accentColour: "#8c8c82",
|
||||
flag: "flag_france.png",
|
||||
@@ -15,6 +16,7 @@ export const CIVILISATIONS = [
|
||||
id: "britain",
|
||||
name: "Britain",
|
||||
demonym: "British",
|
||||
currency: { code: "GBP", symbol: "£", name: "British Pound" },
|
||||
primaryColour: "#a81818",
|
||||
accentColour: "#0722ff",
|
||||
flag: "flag_britain.png",
|
||||
@@ -24,6 +26,7 @@ export const CIVILISATIONS = [
|
||||
id: "slovenia",
|
||||
name: "Slovenia",
|
||||
demonym: "Slovenian",
|
||||
currency: { code: "SIT", symbol: "SIT", name: "Slovenian Tolar" },
|
||||
primaryColour: "#005da4",
|
||||
accentColour: "#ed1c24",
|
||||
flag: "flag_slovenia.png",
|
||||
@@ -33,6 +36,7 @@ export const CIVILISATIONS = [
|
||||
id: "poland",
|
||||
name: "Poland",
|
||||
demonym: "Polish",
|
||||
currency: { code: "PLN", symbol: "zł", name: "Polish Złoty" },
|
||||
primaryColour: "#a6000b",
|
||||
accentColour: "#ffffff",
|
||||
flag: "flag_poland.png",
|
||||
@@ -42,6 +46,7 @@ export const CIVILISATIONS = [
|
||||
id: "switzerland",
|
||||
name: "Switzerland",
|
||||
demonym: "Swiss",
|
||||
currency: { code: "CHF", symbol: "₣", name: "Swiss Franc" },
|
||||
primaryColour: "#da291c",
|
||||
accentColour: "#ffffff",
|
||||
flag: "flag_switzerland.png",
|
||||
@@ -51,6 +56,7 @@ export const CIVILISATIONS = [
|
||||
id: "ticino",
|
||||
name: "Ticino",
|
||||
demonym: "Ticinese",
|
||||
currency: { code: "TIF", symbol: "₣", name: "Ticinese Franc" },
|
||||
primaryColour: "#4646e0",
|
||||
accentColour: "#a6000b",
|
||||
flag: "flag_ticino.png",
|
||||
@@ -60,6 +66,7 @@ export const CIVILISATIONS = [
|
||||
id: "galicia",
|
||||
name: "Galicia",
|
||||
demonym: "Galician",
|
||||
currency: { code: "GAP", symbol: "₧", name: "Galician Peseta" },
|
||||
primaryColour: "#006dad",
|
||||
accentColour: "#ffffff",
|
||||
flag: "flag_galicia.png",
|
||||
@@ -69,6 +76,7 @@ export const CIVILISATIONS = [
|
||||
id: "germany",
|
||||
name: "Germany",
|
||||
demonym: "German",
|
||||
currency: { code: "DEM", symbol: "DM", name: "German Mark" },
|
||||
primaryColour: "#141414",
|
||||
accentColour: "#ffce00",
|
||||
flag: "flag_germany.png",
|
||||
@@ -78,6 +86,7 @@ export const CIVILISATIONS = [
|
||||
id: "iran",
|
||||
name: "Iran",
|
||||
demonym: "Iranian",
|
||||
currency: { code: "IRR", symbol: "﷼", name: "Iranian Rial" },
|
||||
primaryColour: "#18a645",
|
||||
accentColour: "#f01c21",
|
||||
flag: "flag_iran.png",
|
||||
@@ -87,6 +96,7 @@ export const CIVILISATIONS = [
|
||||
id: "usa",
|
||||
name: "United States",
|
||||
demonym: "American",
|
||||
currency: { code: "USD", symbol: "$", name: "US Dollar" },
|
||||
primaryColour: "#3c3b6e",
|
||||
accentColour: "#b22234",
|
||||
flag: "flag_usa.png",
|
||||
@@ -97,6 +107,7 @@ export const CIVILISATIONS = [
|
||||
name: "Aotearoa",
|
||||
demonym: "New Zealander",
|
||||
adjective: "New Zealand",
|
||||
currency: { code: "NZD", symbol: "$", name: "New Zealand Dollar" },
|
||||
primaryColour: "#00247d",
|
||||
accentColour: "#cc142b",
|
||||
flag: "flag_aotearoa.png",
|
||||
@@ -107,6 +118,7 @@ export const CIVILISATIONS = [
|
||||
name: "New Hampshire",
|
||||
demonym: "New Hampshirite",
|
||||
adjective: "New Hampshire",
|
||||
currency: { code: "NHD", symbol: "$", name: "New Hampshire Dollar" },
|
||||
primaryColour: "#12355b",
|
||||
accentColour: "#f2b705",
|
||||
flag: "flag_new_hampshire.png",
|
||||
@@ -116,6 +128,7 @@ export const CIVILISATIONS = [
|
||||
id: "florida",
|
||||
name: "Florida",
|
||||
demonym: "Floridian",
|
||||
currency: { code: "FLD", symbol: "$", name: "Floridian Dollar" },
|
||||
primaryColour: "#782f40",
|
||||
accentColour: "#ceb888",
|
||||
flag: "flag_florida.png",
|
||||
@@ -125,6 +138,7 @@ export const CIVILISATIONS = [
|
||||
id: "australia",
|
||||
name: "Australia",
|
||||
demonym: "Australian",
|
||||
currency: { code: "AUD", symbol: "$", name: "Australian Dollar" },
|
||||
primaryColour: "#00843d",
|
||||
accentColour: "#ffcd00",
|
||||
flag: "flag_australia.png",
|
||||
@@ -134,6 +148,7 @@ export const CIVILISATIONS = [
|
||||
id: "uruguay",
|
||||
name: "Uruguay",
|
||||
demonym: "Uruguayan",
|
||||
currency: { code: "UYU", symbol: "$U", name: "Uruguayan Peso" },
|
||||
primaryColour: "#0038a8",
|
||||
accentColour: "#fcd116",
|
||||
flag: "flag_uruguay.png",
|
||||
@@ -143,6 +158,7 @@ export const CIVILISATIONS = [
|
||||
id: "vietnam",
|
||||
name: "Vietnam",
|
||||
demonym: "Vietnamese",
|
||||
currency: { code: "VND", symbol: "₫", name: "Vietnamese Đồng" },
|
||||
primaryColour: "#da251d",
|
||||
accentColour: "#ffff00",
|
||||
flag: "flag_vietnam.png",
|
||||
@@ -152,6 +168,7 @@ export const CIVILISATIONS = [
|
||||
id: "texas",
|
||||
name: "Texas",
|
||||
demonym: "Texan",
|
||||
currency: { code: "TXD", symbol: "$", name: "Texan Dollar" },
|
||||
primaryColour: "#bf0a30",
|
||||
accentColour: "#002868",
|
||||
flag: "flag_texas.png",
|
||||
@@ -161,6 +178,7 @@ export const CIVILISATIONS = [
|
||||
id: "alabama",
|
||||
name: "Alabama",
|
||||
demonym: "Alabamian",
|
||||
currency: { code: "ALD", symbol: "$", name: "Alabaman Dollar" },
|
||||
primaryColour: "#9e1b32",
|
||||
accentColour: "#ffffff",
|
||||
flag: "flag_alabama.png",
|
||||
|
||||
@@ -7,11 +7,11 @@ export const CITY_GARRISON_HEAL_PER_HOUR = 1;
|
||||
// Combat is fought on a single tile: the attacking stack charges onto the
|
||||
// defending stack's tile and the two share it until one side is wiped out.
|
||||
// Every day a battle lasts the tile loses this fraction of its population and
|
||||
// of its GDP per capita. Once the fighting stops, GDP per capita recovers by
|
||||
// of its production per head. Once the fighting stops, production recovers by
|
||||
// `recoveryPerDay` each day, starting `recoveryDelayDays` after the last battle.
|
||||
export const BATTLE = {
|
||||
populationLossPerDay: 0.3,
|
||||
gdpPerCapitaLossPerDay: 0.3,
|
||||
productionLossPerDay: 0.3,
|
||||
recoveryDelayDays: 30,
|
||||
recoveryPerDay: 100,
|
||||
// Structures on a battlefield take this much damage each day of fighting.
|
||||
@@ -19,19 +19,19 @@ export const BATTLE = {
|
||||
};
|
||||
|
||||
// Pillaging: each unit removes this fraction of the tile's remaining
|
||||
// population, cuts its GDP per capita by this multiplier, and heals one hit
|
||||
// point per this many people driven off.
|
||||
// population, cuts its production per head by this multiplier, and heals one
|
||||
// hit point per this many people driven off.
|
||||
export const PILLAGE = {
|
||||
populationLoss: 0.1,
|
||||
gdpPenalty: 0.7,
|
||||
productionPenalty: 0.7,
|
||||
healPerPeople: 1000,
|
||||
};
|
||||
|
||||
// Trenches: infantry dug into a tile are much harder to dislodge, at the cost
|
||||
// of the local economy. The defense figures are multipliers on a unit's base
|
||||
// of the local production. The defense figures are multipliers on a unit's base
|
||||
// defense; melee attackers get no bonus, artillery is heavily blunted.
|
||||
export const TRENCH = {
|
||||
gdpMultiplier: 0.8,
|
||||
productionMultiplier: 0.8,
|
||||
meleeDefenseMultiplier: 2.0,
|
||||
artilleryDefenseMultiplier: 3.0,
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export const AIR_DEFENSE = {
|
||||
|
||||
// The result of pillaging a tile with `count` units in sequence, starting from
|
||||
// its current population. Each step removes a tenth of what is left and heals
|
||||
// for the people it removed; the GDP-per-capita multiplier compounds. Shared so
|
||||
// for the people it removed; the production multiplier compounds. Shared so
|
||||
// the server and the forecast tooltip compute the same numbers.
|
||||
export function pillageSteps(population, count) {
|
||||
const steps = [];
|
||||
@@ -81,5 +81,5 @@ export function pillageSteps(population, count) {
|
||||
remaining -= removed;
|
||||
steps.push({ removed, remaining, heal: removed / PILLAGE.healPerPeople });
|
||||
}
|
||||
return { steps, gdpMultiplier: Math.pow(PILLAGE.gdpPenalty, count) };
|
||||
return { steps, productionMultiplier: Math.pow(PILLAGE.productionPenalty, count) };
|
||||
}
|
||||
|
||||
@@ -8,12 +8,55 @@ import { HOURS_PER_YEAR } from "../game_clock.js";
|
||||
// rate below is what the per-tick economy actually uses.
|
||||
export const ANNUAL_TAX_RATE = 0.25;
|
||||
|
||||
// The buckets the monthly budget report groups a nation's spending into. Each
|
||||
// row carries a signed cash movement (positive is money into the treasury) and,
|
||||
// for the upkeep rows, the market value of the materials consumed whether they
|
||||
// were drawn from stores or bought. `group` is the heading the table files the
|
||||
// row under.
|
||||
export const BUDGET_EXPENSE_CATEGORIES = [
|
||||
{ id: "buildings", label: "Building upkeep", group: "Upkeep" },
|
||||
{ id: "units", label: "Unit upkeep", group: "Upkeep" },
|
||||
{ id: "works", label: "Tile works", group: "Upkeep" },
|
||||
{ id: "transport", label: "Transport", group: "Upkeep" },
|
||||
{ id: "campaign", label: "Campaign", group: "Government" },
|
||||
{ id: "construction", label: "Construction", group: "Investment" },
|
||||
{ id: "training", label: "Training", group: "Investment" },
|
||||
{ id: "tilebuild", label: "Tile improvements", group: "Investment" },
|
||||
{ id: "provisioning", label: "Provisioning", group: "Resources" },
|
||||
{ id: "energy", label: "Power", group: "Resources" },
|
||||
{ id: "military", label: "Military supplies", group: "Resources" },
|
||||
];
|
||||
|
||||
// The monetary system: each nation issues its own currency and runs a central
|
||||
// bank. Regions are the private sector, each holding a basket of currencies.
|
||||
// When their cash runs out they borrow from the central bank, and the same bank
|
||||
// lets the government run an overdraft. The interest rate is the cost of that
|
||||
// borrowing, not a direct lever on inflation.
|
||||
export const MONEY = {
|
||||
// The central bank's opening lending rate, charged on every debt each year,
|
||||
// and the ceiling a player may set.
|
||||
defaultInterestRate: 0.02,
|
||||
maxInterestRate: 0.5,
|
||||
// Private-sector cash a region opens with, as a share of its yearly GDP.
|
||||
privateCashFraction: 0.5,
|
||||
// Each day a region converts this share of every foreign holding back into its
|
||||
// own currency.
|
||||
repatriationPerDay: 0.05,
|
||||
// How much a currency's value may move from par, either way.
|
||||
maxRateDrift: 0.9,
|
||||
// How hard a day's net foreign demand pushes a currency's value.
|
||||
demandSensitivity: 0.5,
|
||||
// When several regions chase the same scarce surplus, each further buyer
|
||||
// raises the price it pays by this share, capped at `maxBidMultiplier`.
|
||||
bidPremium: 0.3,
|
||||
maxBidMultiplier: 2,
|
||||
};
|
||||
|
||||
export const ECONOMY = {
|
||||
baseGdpPerCapita: 10_000,
|
||||
basePopulation: 100_000,
|
||||
cityTileGdpMultiplier: 2.0,
|
||||
capitalTileGdpMultiplier: 2.5,
|
||||
citySurroundingGdpBonus: 0.3,
|
||||
cityTileProductionMultiplier: 2.0,
|
||||
capitalTileProductionMultiplier: 2.5,
|
||||
citySurroundingProductionBonus: 0.3,
|
||||
gdpPerProductionPoint: 100_000,
|
||||
// 25% of GDP per year, spread evenly over the hours of the year.
|
||||
taxRatePerYear: ANNUAL_TAX_RATE,
|
||||
@@ -22,10 +65,10 @@ export const ECONOMY = {
|
||||
startingBudgetFraction: 0.1,
|
||||
startingBudget: 1_000_000_000,
|
||||
basePopulationGrowthRate: 0.005,
|
||||
// Natural growth of GDP per capita: every day a tile gains this share of the
|
||||
// GDP per capita it opened the game with. The amount is constant, so the
|
||||
// relative growth rate naturally tapers off as the years pass.
|
||||
naturalGdpGrowthPerDay: 0.0001,
|
||||
// Natural growth of production: every day a tile gains this share of the
|
||||
// production it opened the game with. The amount is constant, so the relative
|
||||
// growth rate naturally tapers off as the years pass.
|
||||
naturalProductionGrowthPerDay: 0.0001,
|
||||
|
||||
productionCapacity(gdp) {
|
||||
if (this.gdpPerProductionPoint <= 0) return 0;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// technologies). An effect scales with a level so a building's benefit grows
|
||||
// with each upgrade.
|
||||
|
||||
export const EFFECT_GDP = "gdp";
|
||||
// Every stat that modifies a tile's or a nation's production, in display order.
|
||||
export const EFFECT_PRODUCTION = "production";
|
||||
export const EFFECT_RESEARCH = "research";
|
||||
export const EFFECT_CULTURE = "culture";
|
||||
export const EFFECT_BUDGET_INCOME = "budget_income";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Forms of government a civilisation may adopt. Each grants a flat modifier to
|
||||
// the stats it favours.
|
||||
|
||||
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
|
||||
|
||||
export const GOVERNMENTS = [
|
||||
{
|
||||
@@ -10,7 +10,7 @@ export const GOVERNMENTS = [
|
||||
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
|
||||
baseCost: 0,
|
||||
costGdpFraction: 0.02,
|
||||
effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "monarchy",
|
||||
@@ -18,7 +18,7 @@ export const GOVERNMENTS = [
|
||||
description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.",
|
||||
baseCost: 0,
|
||||
costGdpFraction: 0.03,
|
||||
effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "technocracy",
|
||||
@@ -26,7 +26,7 @@ export const GOVERNMENTS = [
|
||||
description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.",
|
||||
baseCost: 0,
|
||||
costGdpFraction: 0.04,
|
||||
effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)],
|
||||
effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_PRODUCTION, 0.03, 1.0)],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// again as a road. Both are bought outright and then cost an hourly maintenance
|
||||
// for every tile of the network a nation holds.
|
||||
|
||||
import { resourceBuildingById } from "./resources.js";
|
||||
|
||||
export const TRANSPORT_IMPROVEMENTS = [
|
||||
{
|
||||
id: "road",
|
||||
@@ -57,6 +59,7 @@ export const TILE_IMPROVEMENTS = [
|
||||
},
|
||||
{
|
||||
id: "radar_tower",
|
||||
advanced: true,
|
||||
name: "Radar tower",
|
||||
description:
|
||||
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
|
||||
@@ -69,6 +72,7 @@ export const TILE_IMPROVEMENTS = [
|
||||
},
|
||||
{
|
||||
id: "coastal_cannon",
|
||||
advanced: true,
|
||||
name: "Coastal cannon",
|
||||
description:
|
||||
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
|
||||
@@ -86,6 +90,13 @@ export const TILE_IMPROVEMENT_BY_ID = Object.fromEntries(
|
||||
TILE_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
|
||||
);
|
||||
|
||||
// A tile-owned building is either a military improvement or a resource
|
||||
// producer. Both share the tile-improvement maps, the tile-works construction
|
||||
// queue and the hourly upkeep, so the lookup folds the two catalogues together.
|
||||
export function tileImprovementById(id) {
|
||||
return TILE_IMPROVEMENT_BY_ID[id] || null;
|
||||
return TILE_IMPROVEMENT_BY_ID[id] || resourceBuildingById(id) || null;
|
||||
}
|
||||
|
||||
export function isResourceTileBuilding(proto) {
|
||||
return !!(proto && proto.resource && proto.outputPerDay !== undefined);
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ export * from "./politics.js";
|
||||
export * from "./map_config.js";
|
||||
export * from "./terrain.js";
|
||||
export * from "./roads.js";
|
||||
export * from "./resources.js";
|
||||
export * from "./improvements.js";
|
||||
|
||||
@@ -160,6 +160,38 @@ export const MIGRATION = {
|
||||
airportSharePerDay: 0.0005,
|
||||
// The approval at or above which nobody is inclined to leave.
|
||||
comfortableApproval: 0.9,
|
||||
|
||||
// People move only to a region at least this much richer per head, and the
|
||||
// bar rises with the travel time: a destination `time` hours away must be
|
||||
// `incomeRatioBase * (1 + time * incomeTimePenalty)` times as rich. So the
|
||||
// closer and richer a place is, the more attractive.
|
||||
incomeRatioBase: 2,
|
||||
incomeTimePenalty: 0.02,
|
||||
// People travel the transport network; a tile of open terrain costs this many
|
||||
// hours at walking pace, a road or railway less, open water a flat sea rate.
|
||||
peopleSpeed: 1,
|
||||
seaHoursPerTile: 1.5,
|
||||
// Land routes are only traced this far; beyond it a destination is reachable
|
||||
// only by air.
|
||||
maxTravelHours: 120,
|
||||
// Of the people who qualify, this share of the poorer region moves per day,
|
||||
// scaled by how far the ratio clears the bar and capped.
|
||||
incomeSharePerDay: 0.0015,
|
||||
incomeGapCap: 1,
|
||||
// No single day moves more than this share of a region's people by land.
|
||||
incomeMaxSharePerDay: 0.005,
|
||||
// An airport flies people over the distance at a flat time cost, but moves at
|
||||
// most this many people per day for each airport level on the route.
|
||||
airHours: 6,
|
||||
airPeoplePerDayPerLevel: 1000,
|
||||
|
||||
// At generation, regions of a nation trade people until their production per
|
||||
// head is level. `equalizeTolerance` is the residual spread accepted and
|
||||
// `equalizePasses` bounds the refinement; no region is emptied below
|
||||
// `minRegionPopulation`.
|
||||
equalizeTolerance: 0.005,
|
||||
equalizePasses: 30,
|
||||
minRegionPopulation: 20000,
|
||||
};
|
||||
|
||||
// A pillaged tile is a war crime. The people of the tile loathe the aggressor
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// Resources: the five material groups nations produce, store, trade and
|
||||
// consume. Everything is expressed in one canonical unit per resource so the
|
||||
// simulation never has to convert: energy in kWh, steel and food in tonnes,
|
||||
// luxury in carats and high-tech in whole units. Display formatting converts to
|
||||
// the friendly unit (MWh, Mt, kt, ...). The market prices and conversion ratios
|
||||
// here are the main tuning knobs for the whole resource economy; the per-person
|
||||
// needs and the energy-per-material figures come straight from the roadmap.
|
||||
|
||||
// Energy is stored and moved as kWh. These keep the catalogue readable.
|
||||
export const KWH_PER_MWH = 1_000;
|
||||
export const KWH_PER_GWH = 1_000_000;
|
||||
export const KWH_PER_TWH = 1_000_000_000;
|
||||
|
||||
// One tonne of any material counts as this much energy on the wire, so material
|
||||
// deliveries and energy deliveries share one transport-cost formula.
|
||||
export const ENERGY_PER_MATERIAL_TONNE = 10 * KWH_PER_GWH; // 10 GWh/t
|
||||
|
||||
export const RESOURCES = [
|
||||
{
|
||||
id: "energy",
|
||||
name: "Energy",
|
||||
unit: "kWh",
|
||||
icon: "icon_electricity.svg",
|
||||
colour: "#ffd633",
|
||||
// Mass of one canonical unit, so a delivery leg can charge transport per
|
||||
// tonne whatever the good is: 10 GWh of energy weighs a tonne.
|
||||
tonnesPerUnit: 1 / ENERGY_PER_MATERIAL_TONNE,
|
||||
// Energy is a flow, not a stock: it comes from the grid and is spent the
|
||||
// same day. Only cities and resource buildings can hold the other four.
|
||||
storable: false,
|
||||
},
|
||||
{
|
||||
id: "steel",
|
||||
colour: "#9aa7b8",
|
||||
name: "Steel",
|
||||
unit: "t",
|
||||
icon: "icon_steel.svg",
|
||||
tonnesPerUnit: 1,
|
||||
storable: true,
|
||||
},
|
||||
{
|
||||
id: "food",
|
||||
colour: "#7ed957",
|
||||
name: "Food",
|
||||
unit: "t",
|
||||
icon: "icon_food.svg",
|
||||
tonnesPerUnit: 1,
|
||||
storable: true,
|
||||
},
|
||||
{
|
||||
id: "luxury",
|
||||
colour: "#d98cff",
|
||||
name: "Luxury",
|
||||
unit: "ct",
|
||||
icon: "icon_luxury.svg",
|
||||
// A carat is 0.2 g, so 200,000 ct weigh a tonne.
|
||||
tonnesPerUnit: 0.0002 / 1000,
|
||||
storable: true,
|
||||
},
|
||||
{
|
||||
id: "hightech",
|
||||
colour: "#5cc8ff",
|
||||
name: "High-tech",
|
||||
unit: "u",
|
||||
icon: "icon_hightech.svg",
|
||||
// A high-tech unit is a crate of components, about a kilogram.
|
||||
tonnesPerUnit: 0.001,
|
||||
storable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const RESOURCE_IDS = RESOURCES.map((r) => r.id);
|
||||
export const RESOURCE_INDEX = Object.fromEntries(RESOURCES.map((r, i) => [r.id, i]));
|
||||
// The four stockable resources, in catalogue order. Energy is handled as a flow.
|
||||
export const STORABLE_RESOURCE_IDS = RESOURCES.filter((r) => r.storable).map((r) => r.id);
|
||||
|
||||
export function resourceById(id) {
|
||||
return RESOURCES[RESOURCE_INDEX[id]] || null;
|
||||
}
|
||||
|
||||
// Global market prices, in euro per canonical unit. These are what a city pays
|
||||
// when it cannot meet a need from its own store or a neighbour. Energy is
|
||||
// deliberately cheap: the roadmap's energy-per-material ratios are large, so a
|
||||
// high electric price would make every material's energy bill dwarf its value.
|
||||
//
|
||||
// The five figures are the old nominal prices raised roughly seventeen-fold so
|
||||
// the production-based GDP (what a nation makes times these prices) lands in
|
||||
// the same range as the money economy it replaced. The scaling is what makes a
|
||||
// fresh nation's food output alone read as a plausible per-capita GDP rather
|
||||
// than a few hundred euro; construction, upkeep and imports are priced off the
|
||||
// same figures.
|
||||
export const RESOURCE_MARKET_BASE = {
|
||||
energy: 0.051, // per kWh
|
||||
steel: 10_200, // per tonne
|
||||
food: 5_100, // per tonne
|
||||
luxury: 1_020, // per carat
|
||||
hightech: 13_600, // per unit
|
||||
};
|
||||
|
||||
// How strongly a day's world-wide imbalance moves the price, and how far it may
|
||||
// drift from the base. The market is a smoothing device, not a real auction:
|
||||
// it lets a shortage raise the cost of imports and a glut cheapen them.
|
||||
export const RESOURCE_MARKET = {
|
||||
base: RESOURCE_MARKET_BASE,
|
||||
adjustment: 0.2,
|
||||
minMultiplier: 0.5,
|
||||
maxMultiplier: 1.8,
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const RESOURCE_RULES = {
|
||||
// Each person eats 1 kg of food and consumes 1 kg of steel a day. Both are
|
||||
// canonical tonnes, so 1 kg is 0.001 t.
|
||||
foodPerPersonPerDay: 0.001,
|
||||
steelPerPersonPerDay: 0.001,
|
||||
// Luxuries are rarer: 0.01 carat a person a day.
|
||||
luxuryPerPersonPerDay: 0.01,
|
||||
// Cities aim to hold 130% of a month's food before the month begins.
|
||||
foodMonthlyBuffer: 1.3,
|
||||
daysPerMonth: 30,
|
||||
|
||||
// A city's baseline electric appetite, in kWh per euro of yearly GDP. This is
|
||||
// the "kWh per euro of GDP" economic metric; the top bar does not show it.
|
||||
gdpEnergyIntensity: 0.15,
|
||||
|
||||
// Energy the material producers consume to make one canonical unit.
|
||||
energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne
|
||||
energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne
|
||||
energyPerLuxuryCarat: {
|
||||
gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
|
||||
diamond_mine: 5 * KWH_PER_MWH, // 5 MWh per carat
|
||||
},
|
||||
energyPerHighTechUnit: 100 * KWH_PER_MWH, // 100 MWh per unit
|
||||
// Science buildings spend one high-tech unit for every research point.
|
||||
highTechPerResearchPoint: 1,
|
||||
|
||||
// Delivering one tonne of material one tile, in kWh of transport energy.
|
||||
// Roads use trucks, railways trains; without a road a truck burns the
|
||||
// off-road figure times the tile's movement cost, and sea legs go by ship.
|
||||
deliveryEnergy: {
|
||||
truckRoad: 10 * KWH_PER_MWH, // 10 MWh per tonne per tile
|
||||
truckOffRoad: 50 * KWH_PER_MWH, // 50 MWh per tonne per tile, times cost
|
||||
train: 1 * KWH_PER_MWH, // 1 MWh per tonne per tile
|
||||
ship: 50, // 50 kWh per tonne per tile
|
||||
},
|
||||
|
||||
// Steel every construction spends per euro of its money cost. At 1/10000 a
|
||||
// 100 M building needs 10,000 t of steel.
|
||||
steelPerBudgetEuro: 1 / 10_000,
|
||||
// The electricity a tile work draws as it is built, per euro of its money
|
||||
// cost. It is roughly the power embodied in the steel it spends: steel costs
|
||||
// 20 MWh a tonne and 1 t per 10,000 euro, so building is powered as well as
|
||||
// supplied. Tile improvements pay this up front.
|
||||
constructionEnergyPerBudgetEuro: 2, // kWh per euro
|
||||
// High-tech a science or advanced building needs to be raised, per level.
|
||||
constructionHighTechPerLevel: 100,
|
||||
|
||||
// Upkeep is materials, not money: every building, unit and improvement wears
|
||||
// out and the state buys what it needs. Each figure scales with the thing's
|
||||
// money value -- a building level's cost, a unit's training cost -- so one set
|
||||
// of coefficients tunes the whole load. `advanced` things (science, air,
|
||||
// radar, chips, fusion) also need high-tech.
|
||||
upkeepSteelPerEuro: 5e-7, // t per euro of value per day
|
||||
upkeepEnergyPerEuro: 1e-3, // kWh per euro of value per day
|
||||
upkeepHighTechPerEuro: 2e-6, // u per euro of value per day (advanced only)
|
||||
|
||||
// Transport is the exception: its build cost is enormous but its upkeep is a
|
||||
// per-tile wear figure, not a share of that cost.
|
||||
transportUpkeepSteel: { road: 0.02, railway: 0.1 },
|
||||
|
||||
// Combat is the other exception: guns and aircraft are cheap to keep parked
|
||||
// and expensive to fire. These are the materials one use spends. Air defense
|
||||
// fires per hostile aircraft pass, artillery per hourly shelling, an aircraft
|
||||
// per sortie.
|
||||
combatConsumption: {
|
||||
airDefenseVolley: { steel: 20, hightech: 5 },
|
||||
artilleryVolley: { steel: 1, hightech: 0 },
|
||||
airStrike: { steel: 100, hightech: 20 },
|
||||
},
|
||||
|
||||
// Infantry eat. A unit carries this many soldiers, and each is fed like a
|
||||
// civilian: double rations in combat, triple while wounded and resting.
|
||||
// Training or healing a unit draws its soldiers out of the region's people.
|
||||
unitPopulation: 1_000,
|
||||
infantryCombatFoodMultiplier: 2,
|
||||
infantryRestingFoodMultiplier: 3,
|
||||
|
||||
// Supply. A unit carries this many days of its own food. At home, or in
|
||||
// foreign territory with a clear route to an allied city, it tops up from
|
||||
// that city; cut off, the days run down and the unit starts to starve.
|
||||
unitSupplyDays: 50,
|
||||
starvationHpFractionPerDay: 0.1,
|
||||
|
||||
// Repairs: damage leaves a materials debt the owner pays down, and only then
|
||||
// does the structure recover. A razed city level costs what it cost to build.
|
||||
repair: {
|
||||
steelPerTileHp: 0.05, // t to restore one hit point of a tile work
|
||||
tileHpPerDay: 60, // hit points a funded repair restores each day
|
||||
steelPerDamage: 0.5, // t of debt per point of structure damage
|
||||
unitSteelPerHp: 0.01, // t to heal one hit point of a ground or naval unit
|
||||
unitHighTechPerHp: 0.002, // u to heal one hit point of an advanced unit
|
||||
},
|
||||
|
||||
// Fraction of a missing need that turns into a popularity penalty per day,
|
||||
// and the same for a GDP-per-capita shortfall, so shortages are felt.
|
||||
shortagePopularityPenalty: 2,
|
||||
shortageGdpPenalty: 0.05,
|
||||
|
||||
// Food comes from the land, not from the number of people standing on it:
|
||||
// every worked tile grows `foodPerTilePerDay` tonnes a day scaled by its
|
||||
// terrain, so moving people between regions changes only income per head.
|
||||
foodPerTilePerDay: 100,
|
||||
// How fertile each terrain is. A worked tile grows its base output scaled by
|
||||
// this, so forests and plains feed cities while deserts and ice do not.
|
||||
tileFoodFactor: {
|
||||
Forest: 1.4,
|
||||
Hills: 0.7,
|
||||
Land: 1.2,
|
||||
Desert: 0.1,
|
||||
Mountain: 0.2,
|
||||
Tundra: 0.2,
|
||||
Ice: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// How far a city will look for a neighbour's surplus before falling back to the
|
||||
// global market.
|
||||
export const RESOURCE_TRADE_RADIUS = 8;
|
||||
|
||||
// Resource-producing buildings. Built only outside cities, they occupy a land
|
||||
// tile like a military improvement and are managed through the same tile-works
|
||||
// queue. A power plant produces energy; every other building consumes energy
|
||||
// and produces its material. `outputPerDay` is in the resource's canonical
|
||||
// unit, `energyPerDay` in kWh (positive means consumed, negative produced).
|
||||
export const RESOURCE_BUILDINGS = [
|
||||
{
|
||||
id: "coal_power_plant",
|
||||
name: "Coal power plant",
|
||||
description:
|
||||
"Burns coal to make 250 GWh of electricity a day. Cheap to raise, but the fuel bill never stops.",
|
||||
resource: "energy",
|
||||
outputPerDay: 250 * KWH_PER_GWH,
|
||||
fuelEnergyPerDay: 250 * KWH_PER_GWH * 0.25,
|
||||
energyPerDay: 0,
|
||||
buildCost: 500_000_000,
|
||||
maintenance: 20_000,
|
||||
icon: "icon_coal_power_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "natural_gas_power_plant",
|
||||
name: "Natural gas power plant",
|
||||
description:
|
||||
"The cleanest fossil plant: 300 GWh a day for a higher fuel bill.",
|
||||
resource: "energy",
|
||||
outputPerDay: 300 * KWH_PER_GWH,
|
||||
fuelEnergyPerDay: 300 * KWH_PER_GWH * 0.25,
|
||||
energyPerDay: 0,
|
||||
buildCost: 700_000_000,
|
||||
maintenance: 25_000,
|
||||
icon: "icon_gas_power_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "oil_power_plant",
|
||||
name: "Oil power plant",
|
||||
description: "Burns oil for 270 GWh of electricity a day.",
|
||||
resource: "energy",
|
||||
outputPerDay: 270 * KWH_PER_GWH,
|
||||
fuelEnergyPerDay: 270 * KWH_PER_GWH * 0.25,
|
||||
energyPerDay: 0,
|
||||
buildCost: 600_000_000,
|
||||
maintenance: 22_000,
|
||||
icon: "icon_oil_power_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "solar_power_plant",
|
||||
name: "Solar power plant",
|
||||
description:
|
||||
"A field of panels producing a steady 30 GWh a day with no fuel and no upkeep.",
|
||||
resource: "energy",
|
||||
outputPerDay: 30 * KWH_PER_GWH,
|
||||
energyPerDay: 0,
|
||||
renewable: true,
|
||||
buildCost: 300_000_000,
|
||||
maintenance: 0,
|
||||
icon: "icon_solar_power_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "geothermal_plant",
|
||||
name: "Geothermal plant",
|
||||
description: "Taps the earth's heat for 60 GWh a day of free electricity.",
|
||||
resource: "energy",
|
||||
outputPerDay: 60 * KWH_PER_GWH,
|
||||
energyPerDay: 0,
|
||||
renewable: true,
|
||||
buildCost: 800_000_000,
|
||||
maintenance: 0,
|
||||
icon: "icon_geothermal_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "wind_turbines",
|
||||
name: "Wind turbines",
|
||||
description: "A hilltop wind farm making 25 GWh a day for free.",
|
||||
resource: "energy",
|
||||
outputPerDay: 25 * KWH_PER_GWH,
|
||||
energyPerDay: 0,
|
||||
renewable: true,
|
||||
buildCost: 200_000_000,
|
||||
maintenance: 0,
|
||||
icon: "icon_wind_turbines.svg",
|
||||
},
|
||||
{
|
||||
id: "offshore_wind_turbines",
|
||||
name: "Offshore wind turbines",
|
||||
description: "Turbines out at sea making 50 GWh a day. Needs a coastal tile.",
|
||||
resource: "energy",
|
||||
outputPerDay: 50 * KWH_PER_GWH,
|
||||
energyPerDay: 0,
|
||||
renewable: true,
|
||||
coastal: true,
|
||||
buildCost: 500_000_000,
|
||||
maintenance: 0,
|
||||
icon: "icon_offshore_wind_turbines.svg",
|
||||
},
|
||||
{
|
||||
id: "fusion_power_plant",
|
||||
advanced: true,
|
||||
name: "Fusion power plant",
|
||||
description: "A late-game reactor producing 4 TWh of electricity a day.",
|
||||
resource: "energy",
|
||||
outputPerDay: 4_000 * KWH_PER_GWH,
|
||||
energyPerDay: 0,
|
||||
renewable: true,
|
||||
buildCost: 50_000_000_000,
|
||||
maintenance: 50_000,
|
||||
// A wonder of the endgame: only a nation that has mastered fusion may raise
|
||||
// one. Its upkeep is deliberately high but not the raw share of its enormous
|
||||
// build cost.
|
||||
requiresTechnology: "fusion_power",
|
||||
materialUpkeep: { steel: 1_000, energy: 0, hightech: 500 },
|
||||
icon: "icon_fusion_power_plant.svg",
|
||||
},
|
||||
{
|
||||
id: "steel_mill",
|
||||
name: "Steel mill",
|
||||
description:
|
||||
"Smelts iron into 2000 t of steel a day, spending 20 MWh of energy per tonne.",
|
||||
resource: "steel",
|
||||
outputPerDay: 2_000,
|
||||
energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
|
||||
buildCost: 800_000_000,
|
||||
maintenance: 10_000,
|
||||
icon: "icon_steel_mill.svg",
|
||||
},
|
||||
{
|
||||
id: "gold_mine",
|
||||
name: "Gold mine",
|
||||
description: "Produces 20,000 carats of luxury a day, at 10 MWh a carat.",
|
||||
resource: "luxury",
|
||||
outputPerDay: 20_000,
|
||||
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
|
||||
buildCost: 400_000_000,
|
||||
maintenance: 5_000,
|
||||
icon: "icon_gold_mine.svg",
|
||||
},
|
||||
{
|
||||
id: "diamond_mine",
|
||||
name: "Diamond mine",
|
||||
description: "Produces 20,000 carats of luxury a day, at 5 MWh a carat.",
|
||||
resource: "luxury",
|
||||
outputPerDay: 20_000,
|
||||
energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
|
||||
buildCost: 500_000_000,
|
||||
maintenance: 5_000,
|
||||
icon: "icon_diamond_mine.svg",
|
||||
},
|
||||
{
|
||||
id: "chip_foundry",
|
||||
advanced: true,
|
||||
name: "Chip foundry",
|
||||
description:
|
||||
"Fabricates 5000 units of high-tech a day, spending 100 MWh of energy per unit.",
|
||||
resource: "hightech",
|
||||
outputPerDay: 5_000,
|
||||
energyPerDay: 5_000 * RESOURCE_RULES.energyPerHighTechUnit,
|
||||
buildCost: 2_000_000_000,
|
||||
maintenance: 10_000,
|
||||
icon: "icon_chip_foundry.svg",
|
||||
},
|
||||
];
|
||||
|
||||
// One work stands for a whole industry: its output is scaled up so a nation
|
||||
// needs far fewer of them on the map. The energy and fuel it draws scale with
|
||||
// the output too, so a converter stays exactly as efficient as it was authored.
|
||||
export const RESOURCE_BUILDING_OUTPUT_SCALE = 10;
|
||||
for (const building of RESOURCE_BUILDINGS) {
|
||||
building.outputPerDay = (building.outputPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
|
||||
building.energyPerDay = (building.energyPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
|
||||
building.fuelEnergyPerDay = (building.fuelEnergyPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
|
||||
}
|
||||
|
||||
// The resource buildings decay in battle like the military improvements do.
|
||||
for (const building of RESOURCE_BUILDINGS) {
|
||||
if (building.takesCombatDamage === undefined) building.takesCombatDamage = true;
|
||||
}
|
||||
|
||||
export const RESOURCE_BUILDING_BY_ID = Object.fromEntries(
|
||||
RESOURCE_BUILDINGS.map((building) => [building.id, building])
|
||||
);
|
||||
|
||||
export function resourceBuildingById(id) {
|
||||
return RESOURCE_BUILDING_BY_ID[id] || null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
// news event (it only notifies the player).
|
||||
|
||||
import {
|
||||
EFFECT_GDP,
|
||||
EFFECT_PRODUCTION,
|
||||
EFFECT_BUDGET_INCOME,
|
||||
EFFECT_RESEARCH,
|
||||
EFFECT_CULTURE,
|
||||
@@ -36,17 +36,17 @@ export const TECHNOLOGIES = [
|
||||
theme: "science",
|
||||
cost: 20,
|
||||
prerequisites: [],
|
||||
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
|
||||
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_PRODUCTION, 0.02, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "industrial_automation",
|
||||
name: "Industrial Automation",
|
||||
description: "Robotics and control systems take over the factory floor, lifting national output.",
|
||||
description: "Robotics and control systems take over the factory floor, lifting national production.",
|
||||
theme: "economy",
|
||||
repeatable: true,
|
||||
cost: 60,
|
||||
prerequisites: ["scientific_method"],
|
||||
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "renewable_energy",
|
||||
@@ -55,7 +55,7 @@ export const TECHNOLOGIES = [
|
||||
theme: "economy",
|
||||
cost: 80,
|
||||
prerequisites: ["scientific_method"],
|
||||
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "quantum_computing",
|
||||
@@ -73,7 +73,17 @@ export const TECHNOLOGIES = [
|
||||
theme: "science",
|
||||
cost: 300,
|
||||
prerequisites: ["industrial_automation", "quantum_computing"],
|
||||
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
|
||||
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_PRODUCTION, 0.05, 1.0)],
|
||||
},
|
||||
{
|
||||
id: "fusion_power",
|
||||
name: "Fusion Power",
|
||||
description: "Controlled fusion. The key to the fusion power plant, and the most expensive research a nation can undertake.",
|
||||
theme: "science",
|
||||
// 500,000 research points, a lifetime's work for a mature nation.
|
||||
cost: 5_000,
|
||||
prerequisites: ["space_program", "quantum_computing"],
|
||||
effects: [effect(EFFECT_RESEARCH, 8.0, 1.0)],
|
||||
},
|
||||
|
||||
// Repeatable levels. Each raises a single stat by 1% of its base value.
|
||||
@@ -138,11 +148,17 @@ export function technologyById(id) {
|
||||
return TECHNOLOGIES.find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
// Research is deliberately expensive: the catalogue lists the thematic base
|
||||
// price, and every effective cost is scaled by this factor. Keeping the scale
|
||||
// in one place lets the whole tree be retuned without touching each entry.
|
||||
export const RESEARCH_COST_MULTIPLIER = 100;
|
||||
|
||||
// The cost of the next level of a technology given how many times it has been
|
||||
// researched already. Non-repeatable technologies cost the same either way.
|
||||
export function technologyCost(proto, repeatCount = 0) {
|
||||
if (!proto) return 0;
|
||||
if (!proto.repeatable) return proto.cost;
|
||||
const base = proto.cost * RESEARCH_COST_MULTIPLIER;
|
||||
if (!proto.repeatable) return base;
|
||||
const growth = proto.repeatCostGrowth || 0;
|
||||
return proto.cost * Math.pow(1 + growth, Math.max(0, repeatCount));
|
||||
return base * Math.pow(1 + growth, Math.max(0, repeatCount));
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
// the fraction added to a unit's defense while it stands on the tile.
|
||||
|
||||
export const TERRAIN_TILES = [
|
||||
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
|
||||
{ col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
|
||||
{ col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
|
||||
{ col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
|
||||
{ col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
|
||||
{ col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
|
||||
{ col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
|
||||
{ col: 6, terrainType: "Tundra", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 2.0, defenseBonus: 0, minElevation: 0 },
|
||||
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, productionMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
|
||||
{ col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, productionMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
|
||||
{ col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, productionMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
|
||||
{ col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, productionMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
|
||||
{ col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, productionMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
|
||||
{ col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, productionMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
|
||||
{ col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, productionMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
|
||||
{ col: 6, terrainType: "Tundra", terrainClass: "Land", populationMultiplier: 0.05, productionMultiplier: 0.2, movementCostMultiplier: 2.0, defenseBonus: 0, minElevation: 0 },
|
||||
];
|
||||
|
||||
// A city standing on a tile adds this fraction on top of the terrain's own
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export const PROTO_UNITS = [
|
||||
{
|
||||
id: "modern_infantry",
|
||||
population: 1_000,
|
||||
name: "Modern infantry",
|
||||
maxHp: 500,
|
||||
cost: 100_000_000,
|
||||
@@ -40,6 +41,7 @@ export const PROTO_UNITS = [
|
||||
},
|
||||
{
|
||||
id: "artillery",
|
||||
materialUpkeep: { steel: 2, energy: 2000, hightech: 0 },
|
||||
name: "Artillery",
|
||||
maxHp: 400,
|
||||
cost: 300_000_000,
|
||||
@@ -59,6 +61,8 @@ export const PROTO_UNITS = [
|
||||
},
|
||||
{
|
||||
id: "jet_fighter",
|
||||
materialUpkeep: { steel: 5, energy: 5000, hightech: 1 },
|
||||
advanced: true,
|
||||
name: "Jet fighter",
|
||||
maxHp: 300,
|
||||
cost: 400_000_000,
|
||||
@@ -83,6 +87,8 @@ export const PROTO_UNITS = [
|
||||
},
|
||||
{
|
||||
id: "bomber",
|
||||
materialUpkeep: { steel: 8, energy: 8000, hightech: 2 },
|
||||
advanced: true,
|
||||
name: "Bomber",
|
||||
maxHp: 350,
|
||||
cost: 500_000_000,
|
||||
@@ -106,6 +112,7 @@ export const PROTO_UNITS = [
|
||||
},
|
||||
{
|
||||
id: "battleship",
|
||||
advanced: true,
|
||||
name: "Battleship",
|
||||
maxHp: 800,
|
||||
cost: 600_000_000,
|
||||
@@ -123,6 +130,7 @@ export const PROTO_UNITS = [
|
||||
},
|
||||
{
|
||||
id: "mobile_radar",
|
||||
advanced: true,
|
||||
name: "Mobile radar",
|
||||
maxHp: 200,
|
||||
cost: 50_000_000,
|
||||
|
||||
@@ -46,6 +46,9 @@ import { visibilityMethods } from "./game_state/visibility.js";
|
||||
import { serializationMethods } from "./game_state/serialization.js";
|
||||
import { monthlyMethods } from "./game_state/monthly.js";
|
||||
import { warfareMethods } from "./game_state/warfare.js";
|
||||
import { resourceMethods } from "./game_state/resources.js";
|
||||
import { budgetMethods } from "./game_state/budget.js";
|
||||
import { moneyMethods } from "./game_state/money.js";
|
||||
|
||||
export {
|
||||
MAX_NEWS,
|
||||
@@ -119,11 +122,11 @@ export class GameState {
|
||||
// railway lists are rebuilt only when something actually moved.
|
||||
this._improvementVersion = 0;
|
||||
this.tilePopulation = new Map();
|
||||
// Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1).
|
||||
this.tileGdpPenalty = new Map();
|
||||
// Absolute GDP-per-capita lost to fighting on a tile, and the hour its
|
||||
// Per-tile production-per-head multiplier, lowered by pillaging (default 1).
|
||||
this.tileProductionPenalty = new Map();
|
||||
// Absolute production per head lost to fighting on a tile, and the hour its
|
||||
// recovery may begin. A tile leaves both maps once it has recovered.
|
||||
this.tileBattleGdpDeficit = new Map();
|
||||
this.tileBattleProductionDeficit = new Map();
|
||||
this.tileBattleRecoveryStart = new Map();
|
||||
this.budgets = new Map();
|
||||
this.training = new Map();
|
||||
@@ -177,11 +180,11 @@ export class GameState {
|
||||
this._populationVersion = 0;
|
||||
this._ethnicityVersion = 0;
|
||||
this._visibleVersion = 0;
|
||||
// Each land tile's opening GDP per capita, the constant natural growth is
|
||||
// measured from. Static once the world exists.
|
||||
this._gdpBaselineVersion = 0;
|
||||
this._serializedGdpBaselineCache = null;
|
||||
this.tileGdpBaseline = new Map();
|
||||
// Each land tile's opening production per head, the constant natural growth
|
||||
// is measured from. Static once the world exists.
|
||||
this._productionBaselineVersion = 0;
|
||||
this._serializedProductionBaselineCache = null;
|
||||
this.tileProductionBaseline = new Map();
|
||||
// Approval is expensive to weight over every tile and is read far more
|
||||
// often than politics changes, so both the per-city and per-nation results
|
||||
// are memoised against the politics version (and cleared on territory
|
||||
@@ -210,9 +213,9 @@ export class GameState {
|
||||
this._cityEconomyCache = new Map();
|
||||
this._cityEconomyCacheActive = false;
|
||||
// Per-civ factors that map generation picked so every nation starts with
|
||||
// the same population and GDP per capita (see _balanceStartingEconomy).
|
||||
// the same population and production per head (see _balanceStartingEconomy).
|
||||
this._populationBalance = new Map();
|
||||
this._perCapitaBalance = new Map();
|
||||
this._productionBalance = new Map();
|
||||
this._serializedTerritory = [];
|
||||
this._serializedExplored = new Map();
|
||||
this._serializedTileEthnicityCache = null;
|
||||
@@ -259,6 +262,12 @@ export class GameState {
|
||||
// the browser rebuilds the very same islands.
|
||||
this.mapConfig = { ...this.mapConfig, nationCount: this.civilisations.length };
|
||||
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
|
||||
// The small islands become the first capitals, so a nation's position in
|
||||
// this list decides whether it starts island-bound. Shuffle the countries
|
||||
// from the world seed so the same few nations are not always the ones
|
||||
// seeded onto the islands. Players pick their nation from this same order,
|
||||
// and the snapshot ships it, so client and server stay in step.
|
||||
new Random((this.seed ^ 0x1b873593) >>> 0).shuffle(this.civilisations);
|
||||
// Combat rolls (a city losing a building level to a bombardment) draw from
|
||||
// their own generator, seeded from the world so a game is reproducible.
|
||||
this._combatRng = new Random((this.seed ^ 0x5bd1e995) >>> 0);
|
||||
@@ -277,11 +286,13 @@ export class GameState {
|
||||
this._configured = true;
|
||||
|
||||
this._placeSettlements();
|
||||
this._initResources();
|
||||
this._initMoney();
|
||||
this._refreshStatuses();
|
||||
this._revealAll();
|
||||
this._refreshVisibility();
|
||||
this._initMonthly();
|
||||
this._initNaturalGdpGrowth();
|
||||
this._initNaturalProductionGrowth();
|
||||
this._visibilityDirty = false;
|
||||
this._emitChanged();
|
||||
}
|
||||
@@ -339,7 +350,7 @@ export class GameState {
|
||||
|
||||
tickHour() {
|
||||
this.totalHours += 1;
|
||||
// Natural growth is a whole number of days, so the cached per-capita figures
|
||||
// Natural growth is a whole number of days, so the cached production figures
|
||||
// only go stale when a new day begins.
|
||||
if (this.totalHours % HOURS_PER_DAY === 0) this._gdpPerCapitaCache.clear();
|
||||
this._tickMonthly();
|
||||
@@ -357,8 +368,12 @@ export class GameState {
|
||||
this._tickPopulation();
|
||||
this._tickPolitics();
|
||||
this._tickBattleTiles();
|
||||
this._tickGdpRecovery();
|
||||
this._tickProductionRecovery();
|
||||
this._tickMoney();
|
||||
this._tickEconomy();
|
||||
this._tickResources();
|
||||
this._tickRepairs();
|
||||
this._tickUnitSupply();
|
||||
this._refreshStatuses();
|
||||
this._applyDailyStatusEffects();
|
||||
this._resolveCombatRound();
|
||||
@@ -392,5 +407,8 @@ Object.assign(
|
||||
visibilityMethods,
|
||||
serializationMethods,
|
||||
monthlyMethods,
|
||||
warfareMethods
|
||||
warfareMethods,
|
||||
resourceMethods,
|
||||
budgetMethods,
|
||||
moneyMethods
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { key } from "../hex.js";
|
||||
import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
|
||||
import { BUILDING_MECHANIC } from "../data/buildings.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.js";
|
||||
|
||||
export const airMethods = {
|
||||
// ----------------------------------------------------- building rules --
|
||||
@@ -210,13 +211,15 @@ export const airMethods = {
|
||||
}
|
||||
const perCapita = this.getTileGdpPerCapita(city.coords);
|
||||
if (perCapita > 0) {
|
||||
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + perCapita * 0.1;
|
||||
this.tileBattleGdpDeficit.set(k, deficit);
|
||||
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) + perCapita * 0.1;
|
||||
this.tileBattleProductionDeficit.set(k, deficit);
|
||||
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
}
|
||||
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
|
||||
this._razeCityBuildings(city, amount);
|
||||
// The people and their city need materials to put themselves right again.
|
||||
this._recordRepairDebt(city.coords, amount);
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
@@ -248,6 +251,9 @@ export const airMethods = {
|
||||
if (chance > 0 && this._random() < chance) {
|
||||
if (level <= 1) delete city.buildings[index];
|
||||
else city.buildings[index] = level - 1;
|
||||
// The level is not lost for good: it waits to be rebuilt once its
|
||||
// materials are paid for.
|
||||
this._queueCityRepair(city, Number(index), level);
|
||||
razed = true;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +272,9 @@ export const airMethods = {
|
||||
if (!city || city.civ === unit.civ) return false;
|
||||
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE);
|
||||
if (level <= 0) return false;
|
||||
// Air defense is cheap to keep ready and costly to fire: no ammunition,
|
||||
// no volley.
|
||||
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false;
|
||||
const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level));
|
||||
unit.hp -= damage;
|
||||
if (unit.hp > 0) return false;
|
||||
@@ -384,8 +393,8 @@ export const airMethods = {
|
||||
if (path.length < 2) return false;
|
||||
paths.push(path);
|
||||
}
|
||||
const cost = (AIR_STRIKE.cost || 0) * units.length;
|
||||
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
|
||||
// A sortie is paid for in munitions, not money: no materials, no strike.
|
||||
if (!this.payCombatResources(civ, "airStrike", units.length)) return false;
|
||||
this._declareWar(civ, target ? target.civ : city.civ);
|
||||
units.forEach((unit, index) => {
|
||||
unit.path = paths[index];
|
||||
@@ -436,7 +445,11 @@ export const airMethods = {
|
||||
// No garrison left: the battery shells the city itself.
|
||||
const city = this._strikeCityAt(goal, unit.civ);
|
||||
if (city && this._strikeReady(unit, goal)) {
|
||||
this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
|
||||
if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
|
||||
unit.strikeTarget = null;
|
||||
} else {
|
||||
this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
|
||||
}
|
||||
} else {
|
||||
unit.strikeTarget = null;
|
||||
}
|
||||
@@ -446,6 +459,11 @@ export const airMethods = {
|
||||
unit.strikeTarget = null;
|
||||
continue;
|
||||
}
|
||||
// Each hourly volley is a shell spent.
|
||||
if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
|
||||
unit.strikeTarget = null;
|
||||
continue;
|
||||
}
|
||||
const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
|
||||
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
|
||||
damage.set(target, (damage.get(target) || 0) + amount);
|
||||
@@ -461,9 +479,13 @@ export const airMethods = {
|
||||
// ---------------------------------------------------------- training --
|
||||
|
||||
// Places a freshly trained unit: aircraft and land units appear in the city,
|
||||
// while a warship is launched onto the least crowded adjacent sea tile.
|
||||
// while a warship is launched onto the least crowded adjacent sea tile. The
|
||||
// unit's soldiers are drawn at random from the region's people.
|
||||
_spawnTrainedUnit(city, proto) {
|
||||
return this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
|
||||
const unit = this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
|
||||
const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
|
||||
if (soldiers > 0) this.drawRegionPopulation(city, soldiers);
|
||||
return unit;
|
||||
},
|
||||
|
||||
_spawnCoordsFor(city, proto) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// The monthly budget ledger. Money moves in and out of a nation's treasury all
|
||||
// day; this keeps a running total for the calendar month, filed under the
|
||||
// expense categories the budget screen shows, so the panel can report what the
|
||||
// current month has cost so far and what each past month cost.
|
||||
//
|
||||
// Two figures are kept per category. `cash` is the money that actually left the
|
||||
// treasury; `economic` is the market value of the materials the category
|
||||
// consumed, whether they were drawn from the nation's stores or bought -- the
|
||||
// upkeep rows consume stock for free, so their economic cost is the truer
|
||||
// measure of what they took. Income and resource sales are the positive side.
|
||||
//
|
||||
// On every calendar-month rollover the live ledger is frozen into the history
|
||||
// and a fresh one begins, so the panel can page back through the last two years.
|
||||
|
||||
import { BUDGET_EXPENSE_CATEGORIES } from "../data/economy.js";
|
||||
import { MONTH_NAMES } from "../game_clock.js";
|
||||
|
||||
// How many completed months the history keeps. Two years is enough to compare a
|
||||
// season with the one before it without growing the snapshot without bound.
|
||||
export const BUDGET_HISTORY_MONTHS = 24;
|
||||
|
||||
function emptyBudgetLedger() {
|
||||
const cash = { income: 0, sell: 0 };
|
||||
const economic = {};
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||||
cash[category.id] = 0;
|
||||
economic[category.id] = 0;
|
||||
}
|
||||
// Money spent per category, broken down by the resource it bought, so an
|
||||
// expense row can expand to show what it consumed and what it paid.
|
||||
const resourceByCategory = {};
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) resourceByCategory[category.id] = {};
|
||||
return { cash, economic, resourceBuy: {}, resourceSell: {}, resourceByCategory };
|
||||
}
|
||||
|
||||
export const budgetMethods = {
|
||||
_initBudgetHistory() {
|
||||
this.budgetLedger = new Map();
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
this.budgetLedger.set(civ, emptyBudgetLedger());
|
||||
}
|
||||
// Frozen months, oldest first: [{ monthIndex, records: [ledger, ...] }].
|
||||
this.budgetHistory = [];
|
||||
},
|
||||
|
||||
_budgetRecord(civ) {
|
||||
if (!this.budgetLedger) this._initBudgetHistory();
|
||||
let record = this.budgetLedger.get(civ);
|
||||
if (!record) {
|
||||
record = emptyBudgetLedger();
|
||||
this.budgetLedger.set(civ, record);
|
||||
}
|
||||
return record;
|
||||
},
|
||||
|
||||
// A signed cash movement for the month: positive is money into the treasury
|
||||
// (tax, a resource sale, a refund), negative is money spent. Recorded once per
|
||||
// category so the report can total them.
|
||||
_recordBudgetCash(civ, category, amount) {
|
||||
if (civ < 0 || !(amount !== 0)) return;
|
||||
const record = this._budgetRecord(civ);
|
||||
record.cash[category] = (record.cash[category] || 0) + amount;
|
||||
},
|
||||
|
||||
// The market value of the materials one category consumed, whatever their
|
||||
// source, so an upkeep row can show what it cost even when the stores paid.
|
||||
_recordBudgetEconomic(civ, category, amount) {
|
||||
if (civ < 0 || !(amount > 0)) return;
|
||||
const record = this._budgetRecord(civ);
|
||||
record.economic[category] = (record.economic[category] || 0) + amount;
|
||||
},
|
||||
|
||||
// The money one expense category spent on one resource, so the row can be
|
||||
// expanded to show what it bought.
|
||||
_recordBudgetCategoryResource(civ, category, resource, amount) {
|
||||
if (civ < 0 || !(amount > 0)) return;
|
||||
const record = this._budgetRecord(civ);
|
||||
const byResource = record.resourceByCategory[category]
|
||||
|| (record.resourceByCategory[category] = {});
|
||||
byResource[resource] = (byResource[resource] || 0) + amount;
|
||||
},
|
||||
|
||||
// The money a nation spent buying and earned selling one resource to the
|
||||
// world market or another country, for the month's resource-trade memo.
|
||||
_recordBudgetResource(civ, resource, bought, sold) {
|
||||
if (civ < 0) return;
|
||||
const record = this._budgetRecord(civ);
|
||||
if (bought > 0) record.resourceBuy[resource] = (record.resourceBuy[resource] || 0) + bought;
|
||||
if (sold > 0) record.resourceSell[resource] = (record.resourceSell[resource] || 0) + sold;
|
||||
},
|
||||
|
||||
// Freezes the month that just ended and starts a new ledger. Called from the
|
||||
// monthly rollover with the month index that is being closed.
|
||||
_rolloverBudget(previousIndex) {
|
||||
if (!this.budgetLedger) return;
|
||||
const records = [];
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
records.push(this.budgetLedger.get(civ) || emptyBudgetLedger());
|
||||
}
|
||||
this.budgetHistory.push({ monthIndex: previousIndex, records });
|
||||
while (this.budgetHistory.length > BUDGET_HISTORY_MONTHS) this.budgetHistory.shift();
|
||||
this.budgetLedger = new Map();
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
this.budgetLedger.set(civ, emptyBudgetLedger());
|
||||
}
|
||||
},
|
||||
|
||||
// Every month the panel may show, oldest first, with the live month last.
|
||||
budgetMonthReports(civ) {
|
||||
const reports = [];
|
||||
for (const entry of this.budgetHistory || []) {
|
||||
reports.push(this._budgetReport(civ, entry.monthIndex, entry.records[civ], false));
|
||||
}
|
||||
const currentIndex = this._monthIndexOf(this.totalHours);
|
||||
reports.push(this._budgetReport(
|
||||
civ,
|
||||
currentIndex,
|
||||
this.budgetLedger ? this.budgetLedger.get(civ) : null,
|
||||
true
|
||||
));
|
||||
return reports;
|
||||
},
|
||||
|
||||
_budgetReport(civ, monthIndex, record, isCurrent) {
|
||||
const cash = (record && record.cash) || {};
|
||||
const economic = (record && record.economic) || {};
|
||||
const expenses = [];
|
||||
let totalCash = 0;
|
||||
let totalEconomic = 0;
|
||||
const resourceByCategory = (record && record.resourceByCategory) || {};
|
||||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||||
const spent = Math.max(0, -(cash[category.id] || 0));
|
||||
const cost = Math.max(0, economic[category.id] || 0, spent);
|
||||
if (spent === 0 && cost === 0) continue;
|
||||
const buys = Object.entries(resourceByCategory[category.id] || {})
|
||||
.filter(([, amount]) => amount > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
expenses.push({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
group: category.group,
|
||||
cash: spent,
|
||||
economic: cost,
|
||||
buys,
|
||||
});
|
||||
totalCash += spent;
|
||||
totalEconomic += cost;
|
||||
}
|
||||
const resourceBuy = (record && record.resourceBuy) || {};
|
||||
const resourceSell = (record && record.resourceSell) || {};
|
||||
const resourceIds = new Set([...Object.keys(resourceBuy), ...Object.keys(resourceSell)]);
|
||||
const resourceTrade = Array.from(resourceIds)
|
||||
.map((id) => ({
|
||||
id,
|
||||
bought: Math.max(0, resourceBuy[id] || 0),
|
||||
sold: Math.max(0, resourceSell[id] || 0),
|
||||
}))
|
||||
.filter((entry) => entry.bought > 0 || entry.sold > 0);
|
||||
const income = cash.income || 0;
|
||||
const sales = cash.sell || 0;
|
||||
const year = Math.floor(monthIndex / 12);
|
||||
const month = monthIndex % 12;
|
||||
return {
|
||||
monthIndex,
|
||||
isCurrent,
|
||||
label: `${MONTH_NAMES[month]} ${year}`,
|
||||
income,
|
||||
sales,
|
||||
expenses,
|
||||
resourceTrade,
|
||||
totalCash,
|
||||
totalEconomic,
|
||||
net: income + sales - totalCash,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -359,8 +359,8 @@ export const combatMethods = {
|
||||
},
|
||||
|
||||
// Every tile where a battle is raging loses a share of its population and its
|
||||
// GDP per capita each day, and its recovery clock is pushed back to 30 days
|
||||
// after the last day of fighting.
|
||||
// production per head each day, and its recovery clock is pushed back to 30
|
||||
// days after the last day of fighting.
|
||||
_tickBattleTiles() {
|
||||
this._populationVersion += 1;
|
||||
for (const battle of this._battleTiles()) {
|
||||
@@ -372,11 +372,11 @@ export const combatMethods = {
|
||||
this._recordBattleCivilianCasualties(battle, removed);
|
||||
}
|
||||
const perCapita = this.getTileGdpPerCapita(battle.coords);
|
||||
let scarred = this.tileBattleGdpDeficit.has(k);
|
||||
let scarred = this.tileBattleProductionDeficit.has(k);
|
||||
if (perCapita > 0) {
|
||||
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) +
|
||||
perCapita * BATTLE.gdpPerCapitaLossPerDay;
|
||||
this.tileBattleGdpDeficit.set(k, deficit);
|
||||
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) +
|
||||
perCapita * BATTLE.productionLossPerDay;
|
||||
this.tileBattleProductionDeficit.set(k, deficit);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
scarred = true;
|
||||
}
|
||||
@@ -391,18 +391,21 @@ export const combatMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Once a battlefield has been quiet for `recoveryDelayDays`, its GDP per
|
||||
// capita climbs back by `recoveryPerDay` a day until the scar is gone.
|
||||
_tickGdpRecovery() {
|
||||
for (const [k, deficit] of Array.from(this.tileBattleGdpDeficit)) {
|
||||
// Once a battlefield has been quiet for `recoveryDelayDays`, its production
|
||||
// per head climbs back by `recoveryPerDay` a day until the scar is gone.
|
||||
_tickProductionRecovery() {
|
||||
for (const [k, deficit] of Array.from(this.tileBattleProductionDeficit)) {
|
||||
const start = this.tileBattleRecoveryStart.get(k);
|
||||
if (start === undefined || this.totalHours < start) continue;
|
||||
// A tile still carrying battle damage it has not paid to repair does not
|
||||
// recover: the materials come first.
|
||||
if ((this.tileRepairDebt.get(k) || 0) > 0.0001) continue;
|
||||
const remaining = deficit - BATTLE.recoveryPerDay;
|
||||
if (remaining <= 0) {
|
||||
this.tileBattleGdpDeficit.delete(k);
|
||||
this.tileBattleProductionDeficit.delete(k);
|
||||
this.tileBattleRecoveryStart.delete(k);
|
||||
} else {
|
||||
this.tileBattleGdpDeficit.set(k, remaining);
|
||||
this.tileBattleProductionDeficit.set(k, remaining);
|
||||
}
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// research/culture accrual and the daily economic ticks.
|
||||
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES, RESOURCE_IDS } from "../data/resources.js";
|
||||
import {
|
||||
EFFECT_GDP,
|
||||
EFFECT_PRODUCTION,
|
||||
EFFECT_BUDGET_INCOME,
|
||||
EFFECT_RESEARCH,
|
||||
EFFECT_CULTURE,
|
||||
@@ -16,15 +17,20 @@ import { technologyCost, technologyById } from "../data/technologies.js";
|
||||
import { NEWS_TECHNOLOGY } from "../data/relations.js";
|
||||
import {
|
||||
buildingUpkeep,
|
||||
gdpPerCapitaFactors,
|
||||
gdpPerCapitaFromFactors,
|
||||
productionFactors,
|
||||
productionFromFactors,
|
||||
governmentEffects,
|
||||
prerequisitesMet,
|
||||
technologyEffects,
|
||||
} from "../rules.js";
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { TRANSPORT_BY_ID } from "../data/improvements.js";
|
||||
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
|
||||
import {
|
||||
TRANSPORT_BY_ID,
|
||||
tileImprovementById,
|
||||
isResourceTileBuilding,
|
||||
} from "../data/improvements.js";
|
||||
import { tileFoodOutput } from "../resources.js";
|
||||
import { HOURS_PER_DAY, HOURS_PER_YEAR, DAYS_PER_YEAR } from "./constants.js";
|
||||
import { effectAt, mergeModifiers } from "./helpers.js";
|
||||
|
||||
export const economyMethods = {
|
||||
@@ -36,22 +42,22 @@ export const economyMethods = {
|
||||
|
||||
// The live economy: the raw sums over the civ's territory, adjusted by the
|
||||
// balancing factors map generation assigned so every civilisation starts with
|
||||
// the same population and GDP per capita. The factors stay applied as the raw
|
||||
// figures grow, so terrain and war keep shaping the economy without ever
|
||||
// recreating the arbitrary starting gap.
|
||||
// the same population and production per head. The factors stay applied as
|
||||
// the raw figures grow, so terrain and war keep shaping the economy without
|
||||
// ever recreating the arbitrary starting gap.
|
||||
_playerAggregates(civ) {
|
||||
const base = this._basePlayerAggregates(civ);
|
||||
const populationScale = this._populationBalance.get(civ) || 1;
|
||||
const perCapitaScale = this._perCapitaBalance.get(civ) || 1;
|
||||
const productionScale = this._productionBalance.get(civ) || 1;
|
||||
return {
|
||||
population: base.population * populationScale,
|
||||
gdp: base.gdp * populationScale * perCapitaScale,
|
||||
gdp: base.gdp * populationScale * productionScale,
|
||||
};
|
||||
},
|
||||
|
||||
// The economy exactly as the tiles describe it, before balancing. Every
|
||||
// tile's per-capita figure already carries its region's and its nation's
|
||||
// modifiers, so the sum needs no further adjustment.
|
||||
// production modifiers, so the sum needs no further adjustment.
|
||||
_basePlayerAggregates(civ) {
|
||||
let population = 0;
|
||||
let baseGdp = 0;
|
||||
@@ -73,60 +79,103 @@ export const economyMethods = {
|
||||
const region = city || this.regionCityAt(coords);
|
||||
const regional = region ? this.getCityBuildingModifiers(region) : null;
|
||||
const national = this.getNationalModifiers(owner);
|
||||
const factors = gdpPerCapitaFactors(this.tiles[k], {
|
||||
const factors = productionFactors(this.tiles[k], {
|
||||
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
|
||||
nextToCity: !city && this._isNextToCity(coords, owner),
|
||||
pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
|
||||
pillagePenalty: this.tileProductionPenalty.get(k) !== undefined
|
||||
? this.tileProductionPenalty.get(k)
|
||||
: 1,
|
||||
// Fighting scars the tile too, but that loss recovers over time.
|
||||
battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
|
||||
battleDeficit: this.tileBattleProductionDeficit.get(k) || 0,
|
||||
trench: this.trenches.has(k),
|
||||
regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
|
||||
regionalMultiplier: 1 + ((regional && regional[EFFECT_PRODUCTION]) || 0),
|
||||
regionalLabel: region ? region.name : null,
|
||||
nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
|
||||
portGdp: this._regionPortGdp(region),
|
||||
naturalGrowth: this._naturalTileGdpGrowth(k),
|
||||
nationalMultiplier: 1 + (national[EFFECT_PRODUCTION] || 0),
|
||||
portProduction: this._regionPortProduction(region),
|
||||
naturalGrowth: this._naturalTileProductionGrowth(k),
|
||||
});
|
||||
const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors);
|
||||
const perCapita = productionFromFactors(this._regionProductionPerCapita(region), factors);
|
||||
this._gdpPerCapitaCache.set(k, perCapita);
|
||||
return perCapita;
|
||||
},
|
||||
|
||||
// Snapshots every land tile's opening GDP per capita, so natural growth has a
|
||||
// GDP does not start from an abstract base figure: the ground a region covers
|
||||
// produces resources, and their value on the market is what its people make.
|
||||
// The base the production modifiers scale is that regional production, in
|
||||
// euro a year, per head. It carries no city, port or terrain bonus of its own
|
||||
// -- those stay in `productionFactors`, applied on top.
|
||||
_regionProductionPerCapita(region) {
|
||||
if (!region) return 0;
|
||||
const cachedKey = `regionbase:${region.id}`;
|
||||
if (this._gdpPerCapitaCache.has(cachedKey)) return this._gdpPerCapitaCache.get(cachedKey);
|
||||
let population = 0;
|
||||
for (const coords of this.regionTiles(region)) {
|
||||
population += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
|
||||
}
|
||||
const base = population > 0 ? this._regionProductionValue(region) / population : 0;
|
||||
this._gdpPerCapitaCache.set(cachedKey, base);
|
||||
return base;
|
||||
},
|
||||
|
||||
// The market value of everything made across a region in a year: the food its
|
||||
// worked land grows (from the land, not the headcount) plus the output of
|
||||
// every resource building on it, each at the current world price.
|
||||
_regionProductionValue(region) {
|
||||
let daily = 0;
|
||||
const foodMultiplier = this._regionFoodMultiplier(region);
|
||||
for (const coords of this.regionTiles(region)) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const tile = this.tiles[k];
|
||||
if (!tile) continue;
|
||||
const population = this.tilePopulation.get(k) || 0;
|
||||
if (population > 0) {
|
||||
daily += tileFoodOutput(tile) * foodMultiplier * this.getResourcePrice("food");
|
||||
}
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
if (isResourceTileBuilding(proto)) {
|
||||
daily += (proto.outputPerDay || 0) * this.getResourcePrice(proto.resource);
|
||||
}
|
||||
}
|
||||
return daily * DAYS_PER_YEAR;
|
||||
},
|
||||
|
||||
// Snapshots every land tile's opening production, so natural growth has a
|
||||
// constant to build on. The world and its economy must exist by now.
|
||||
_initNaturalGdpGrowth() {
|
||||
this.tileGdpBaseline = new Map();
|
||||
if (ECONOMY.naturalGdpGrowthPerDay <= 0) return;
|
||||
_initNaturalProductionGrowth() {
|
||||
this.tileProductionBaseline = new Map();
|
||||
if (ECONOMY.naturalProductionGrowthPerDay <= 0) return;
|
||||
for (const k in this.tiles) {
|
||||
const coords = parseKey(k);
|
||||
if (!this._isLand(coords)) continue;
|
||||
const perCapita = this.getTileGdpPerCapita(coords);
|
||||
if (perCapita > 0) this.tileGdpBaseline.set(k, perCapita);
|
||||
if (perCapita > 0) this.tileProductionBaseline.set(k, perCapita);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
},
|
||||
|
||||
// The flat amount a tile's GDP per capita has gained since January 2000: its
|
||||
// opening figure times the daily rate, once per day elapsed. A constant, so
|
||||
// the relative growth rate falls as the tile grows richer.
|
||||
_naturalTileGdpGrowth(k) {
|
||||
const baseline = this.tileGdpBaseline ? this.tileGdpBaseline.get(k) : 0;
|
||||
// The flat amount a tile's production per head has gained since January 2000:
|
||||
// its opening figure times the daily rate, once per day elapsed. A constant,
|
||||
// so the relative growth rate falls as the tile grows richer.
|
||||
_naturalTileProductionGrowth(k) {
|
||||
const baseline = this.tileProductionBaseline ? this.tileProductionBaseline.get(k) : 0;
|
||||
if (!baseline) return 0;
|
||||
return baseline * ECONOMY.naturalGdpGrowthPerDay * this._gdpGrowthDays();
|
||||
return baseline * ECONOMY.naturalProductionGrowthPerDay * this._productionGrowthDays();
|
||||
},
|
||||
|
||||
_gdpGrowthDays() {
|
||||
_productionGrowthDays() {
|
||||
return Math.floor(Math.max(0, this.totalHours) / HOURS_PER_DAY);
|
||||
},
|
||||
|
||||
// The flat GDP per capita a region's ports add to every tile it covers:
|
||||
// The flat production per head a region's ports add to every tile it covers:
|
||||
// 1000 per level for each sea tile beside the city, summed over its ports.
|
||||
_regionPortGdp(region) {
|
||||
_regionPortProduction(region) {
|
||||
if (!region) return 0;
|
||||
let bonus = 0;
|
||||
for (const [index, level] of Object.entries(region.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_GDP) {
|
||||
bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
|
||||
bonus += (proto.productionPerSeaTilePerLevel || 0) * level;
|
||||
}
|
||||
}
|
||||
if (bonus === 0) return 0;
|
||||
@@ -138,8 +187,32 @@ export const economyMethods = {
|
||||
return bonus * sea;
|
||||
},
|
||||
|
||||
// The extra food a region's ports bring in, as a multiplier on its tiles'
|
||||
// output: each port level adds `foodPerSeaTilePerLevel` for every sea tile
|
||||
// beside the port city. Returns 1 when the region has no port, so the common
|
||||
// inland case is free.
|
||||
_regionFoodMultiplier(region) {
|
||||
if (!region) return 1;
|
||||
let bonus = 0;
|
||||
for (const [index, level] of Object.entries(region.buildings || {})) {
|
||||
const proto = this.protoBuildings[index];
|
||||
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
|
||||
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
|
||||
}
|
||||
}
|
||||
if (bonus === 0) return 1;
|
||||
let sea = 0;
|
||||
for (const neighbour of this._neighbours(region.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") sea += 1;
|
||||
}
|
||||
return 1 + bonus * sea;
|
||||
},
|
||||
|
||||
getTileGdp(coords) {
|
||||
return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords);
|
||||
// The population is rounded, as everywhere it is shown, so a tile's GDP is
|
||||
// exactly its shown people times its shown production per head.
|
||||
return this.getTilePopulation(coords) * this.getTileGdpPerCapita(coords);
|
||||
},
|
||||
|
||||
getPlayerGdp(civ) {
|
||||
@@ -147,21 +220,28 @@ export const economyMethods = {
|
||||
},
|
||||
|
||||
getPlayerUpkeep(civ) {
|
||||
let total = this.getBuildingUpkeep(civ);
|
||||
const battle = this._battleUnitIds();
|
||||
for (const unit of this.units) {
|
||||
if (unit.civ !== civ) continue;
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) continue;
|
||||
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
||||
total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
|
||||
// Money upkeep is now only propaganda. Everything physical -- buildings,
|
||||
// units, works, transport -- is maintained in materials and settled by the
|
||||
// daily resource tick, so it never appears as a direct money bill.
|
||||
return this.getCampaignUpkeep(civ);
|
||||
},
|
||||
|
||||
// The maintenance materials one nation's buildings wear out in a day, for the
|
||||
// budget forecast and the resources panel.
|
||||
getBuildingUpkeep(civ) {
|
||||
const total = { steel: 0, energy: 0, hightech: 0 };
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
for (const [indexStr, level] of Object.entries(city.buildings || {})) {
|
||||
if (level <= 0) continue;
|
||||
const proto = this.protoBuildings[Number(indexStr)];
|
||||
if (!proto) continue;
|
||||
const costs = this.buildingUpkeepResources(proto, level);
|
||||
total.steel += costs.steel;
|
||||
total.energy += costs.energy;
|
||||
total.hightech += costs.hightech;
|
||||
}
|
||||
}
|
||||
// Running propaganda campaigns are upkeep too, paid hour by hour.
|
||||
total += this.getCampaignUpkeep(civ);
|
||||
// The transport network and military tile improvements cost an hourly
|
||||
// maintenance per tile and structure held.
|
||||
total += this.getTransportUpkeep(civ);
|
||||
total += this.getTileImprovementUpkeep(civ);
|
||||
return total;
|
||||
},
|
||||
|
||||
@@ -180,12 +260,12 @@ export const economyMethods = {
|
||||
return counts;
|
||||
},
|
||||
|
||||
// The hourly maintenance of a nation's transport network: every road tile it
|
||||
// holds costs a flat fee, every railway tile three times as much.
|
||||
// The daily steel a nation's transport network wears out: every road tile a
|
||||
// little, every railway tile more.
|
||||
getTransportUpkeep(civ) {
|
||||
const counts = this._transportTiles(civ);
|
||||
return counts.road * TRANSPORT_BY_ID.road.maintenance +
|
||||
counts.railway * TRANSPORT_BY_ID.railway.maintenance;
|
||||
return counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
|
||||
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
|
||||
},
|
||||
|
||||
// The road and railway lines of the budget breakdown, dropping any empty kind.
|
||||
@@ -193,34 +273,34 @@ export const economyMethods = {
|
||||
const counts = this._transportTiles(civ);
|
||||
const sources = [];
|
||||
if (counts.road > 0) {
|
||||
const steel = counts.road * RESOURCE_RULES.transportUpkeepSteel.road;
|
||||
sources.push({
|
||||
label: TRANSPORT_BY_ID.road.name,
|
||||
kind: "transport",
|
||||
count: counts.road,
|
||||
amount: counts.road * TRANSPORT_BY_ID.road.maintenance,
|
||||
resources: { steel, energy: 0, hightech: 0 },
|
||||
amount: this._upkeepCost({ steel }),
|
||||
});
|
||||
}
|
||||
if (counts.railway > 0) {
|
||||
const steel = counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
|
||||
sources.push({
|
||||
label: TRANSPORT_BY_ID.railway.name,
|
||||
kind: "transport",
|
||||
count: counts.railway,
|
||||
amount: counts.railway * TRANSPORT_BY_ID.railway.maintenance,
|
||||
resources: { steel, energy: 0, hightech: 0 },
|
||||
amount: this._upkeepCost({ steel }),
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
},
|
||||
|
||||
getBuildingUpkeep(civ) {
|
||||
let total = 0;
|
||||
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], city.buildings[indexStr]);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
// The market value of a day's upkeep materials: what the state would pay to
|
||||
// buy them, which is what the budget panel forecasts.
|
||||
_upkeepCost(costs) {
|
||||
return (costs.steel || 0) * this.getResourcePrice("steel") +
|
||||
(costs.energy || 0) * this.getResourcePrice("energy") +
|
||||
(costs.hightech || 0) * this.getResourcePrice("hightech");
|
||||
},
|
||||
|
||||
getCityBuildingLevel(city, protoIndex) {
|
||||
@@ -250,9 +330,9 @@ export const economyMethods = {
|
||||
// civilisation's start equal; they scale the region figures too, so the
|
||||
// regions of a nation always add up to its headline population and GDP.
|
||||
const populationScale = this._populationBalance.get(city.civ) || 1;
|
||||
const perCapitaScale = this._perCapitaBalance.get(city.civ) || 1;
|
||||
const productionScale = this._productionBalance.get(city.civ) || 1;
|
||||
population *= populationScale;
|
||||
gdp *= populationScale * perCapitaScale;
|
||||
gdp *= populationScale * productionScale;
|
||||
const rate = ECONOMY.budgetGdpRate;
|
||||
const base = gdp * rate;
|
||||
const cityMods = this.getCityBuildingModifiers(city);
|
||||
@@ -327,6 +407,14 @@ export const economyMethods = {
|
||||
const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
|
||||
const tileImprovementUpkeep =
|
||||
tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
|
||||
// What the nation spent buying and earned selling each resource over the
|
||||
// last day, for the budget panel's trade section.
|
||||
const record = this.resourceSpend.get(civ) || {};
|
||||
const buy = record.buy || {};
|
||||
const sell = record.sell || {};
|
||||
const resourceMoney = RESOURCE_IDS
|
||||
.map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
|
||||
.filter((entry) => entry.bought > 0 || entry.sold > 0);
|
||||
return {
|
||||
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
|
||||
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
|
||||
@@ -338,6 +426,7 @@ export const economyMethods = {
|
||||
sources: tileImprovementSources,
|
||||
groups: tileImprovementSources,
|
||||
},
|
||||
resourceMoney,
|
||||
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
|
||||
};
|
||||
},
|
||||
@@ -392,13 +481,17 @@ export const economyMethods = {
|
||||
for (const indexStr of Object.keys(city.buildings)) {
|
||||
const index = Number(indexStr);
|
||||
const level = city.buildings[indexStr];
|
||||
const amount = buildingUpkeep(this.protoBuildings[index], level);
|
||||
const proto = this.protoBuildings[index];
|
||||
if (!proto || level <= 0) continue;
|
||||
const resources = this.buildingUpkeepResources(proto, level);
|
||||
const amount = this._upkeepCost(resources);
|
||||
if (amount === 0) continue;
|
||||
groups.push({
|
||||
index,
|
||||
label: this.protoBuildings[index].name,
|
||||
label: proto.name,
|
||||
amount,
|
||||
cities: [{ cityId: city.id, cityName: city.name, level, amount }],
|
||||
resources,
|
||||
cities: [{ cityId: city.id, cityName: city.name, level, amount, resources }],
|
||||
});
|
||||
}
|
||||
return groups.sort((a, b) => b.amount - a.amount);
|
||||
@@ -465,16 +558,23 @@ export const economyMethods = {
|
||||
for (const indexStr of Object.keys(city.buildings)) {
|
||||
const index = Number(indexStr);
|
||||
const level = city.buildings[indexStr];
|
||||
const amount = buildingUpkeep(this.protoBuildings[index], level);
|
||||
const proto = this.protoBuildings[index];
|
||||
if (!proto || level <= 0) continue;
|
||||
const resources = this.buildingUpkeepResources(proto, level);
|
||||
const amount = this._upkeepCost(resources);
|
||||
if (amount === 0) continue;
|
||||
const group = byType.get(index) || {
|
||||
index,
|
||||
label: this.protoBuildings[index].name,
|
||||
label: proto.name,
|
||||
amount: 0,
|
||||
resources: { steel: 0, energy: 0, hightech: 0 },
|
||||
cities: [],
|
||||
};
|
||||
group.amount += amount;
|
||||
group.cities.push({ cityId: city.id, cityName: city.name, level, amount });
|
||||
group.resources.steel += resources.steel;
|
||||
group.resources.energy += resources.energy;
|
||||
group.resources.hightech += resources.hightech;
|
||||
group.cities.push({ cityId: city.id, cityName: city.name, level, amount, resources });
|
||||
byType.set(index, group);
|
||||
}
|
||||
}
|
||||
@@ -488,6 +588,7 @@ export const economyMethods = {
|
||||
label: group.label,
|
||||
kind: "building",
|
||||
amount: group.amount,
|
||||
resources: group.resources,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -497,10 +598,27 @@ export const economyMethods = {
|
||||
for (const unit of this.units) {
|
||||
if (unit.civ !== civ) continue;
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto || !proto.upkeep) continue;
|
||||
if (!proto) continue;
|
||||
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
||||
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
|
||||
entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
|
||||
const multiplier = this._statusUpkeepMultiplier(statuses);
|
||||
const costs = this.unitUpkeepResources(proto);
|
||||
const resources = {
|
||||
steel: costs.steel * multiplier,
|
||||
energy: 0,
|
||||
hightech: costs.hightech * multiplier,
|
||||
};
|
||||
const amount = this._upkeepCost(resources);
|
||||
if (amount === 0) continue;
|
||||
const entry = sources.get(unit.proto) || {
|
||||
label: proto.name,
|
||||
kind: "unit",
|
||||
amount: 0,
|
||||
resources: { steel: 0, energy: 0, hightech: 0 },
|
||||
count: 0,
|
||||
};
|
||||
entry.amount += amount;
|
||||
entry.resources.steel += resources.steel;
|
||||
entry.resources.hightech += resources.hightech;
|
||||
entry.count += 1;
|
||||
sources.set(unit.proto, entry);
|
||||
}
|
||||
@@ -604,6 +722,13 @@ export const economyMethods = {
|
||||
return set ? set.has(index) : false;
|
||||
},
|
||||
|
||||
// Whether a nation knows a technology, by its catalogue id. Used to gate the
|
||||
// things only late research unlocks (the fusion plant).
|
||||
hasTechnology(civ, id) {
|
||||
const index = this.technologies.findIndex((tech) => tech.id === id);
|
||||
return index >= 0 && this.isResearched(civ, index);
|
||||
},
|
||||
|
||||
getResearchedIndices(civ) {
|
||||
const set = this.researched.get(civ) || new Set();
|
||||
return Array.from(set);
|
||||
@@ -740,13 +865,38 @@ export const economyMethods = {
|
||||
|
||||
// Deducts `amount` from a nation's budget, returning false (and charging
|
||||
// nothing) when it cannot afford it. A non-positive amount is always free.
|
||||
_spendBudget(civ, amount) {
|
||||
// When a `category` is given the spend is filed into the month's budget
|
||||
// ledger, so the panel can report what each kind of outlay cost.
|
||||
// The government can always pay: a spend is never refused for want of money.
|
||||
// A treasury driven below zero is an overdraft, a debt owed to the central
|
||||
// bank, and carries interest until the budget recovers.
|
||||
_spendBudget(civ, amount, category = null) {
|
||||
if (amount <= 0) return true;
|
||||
if (this.getBudget(civ) < amount) return false;
|
||||
this.budgets.set(civ, this.getBudget(civ) - amount);
|
||||
// The money does not vanish: it is paid into the nation's regions, the
|
||||
// private sector that supplied the goods, labour or services.
|
||||
this._creditRegions(civ, amount);
|
||||
if (category) this._recordBudgetCash(civ, category, -amount);
|
||||
return true;
|
||||
},
|
||||
|
||||
// How far a nation's treasury has been driven into the red, owed to its
|
||||
// central bank.
|
||||
getGovernmentDebt(civ) {
|
||||
return Math.max(0, -this.getBudget(civ));
|
||||
},
|
||||
|
||||
// Puts money back into the treasury, reversing a spend. With a category it
|
||||
// cancels part of that category's month total, so a refunded order does not
|
||||
// linger as an expense.
|
||||
_refundBudget(civ, amount, category = null) {
|
||||
if (!(amount > 0)) return;
|
||||
this.budgets.set(civ, this.getBudget(civ) + amount);
|
||||
// A refund claws the money back from the regions it was paid to.
|
||||
this._chargeRegions(civ, amount);
|
||||
if (category) this._recordBudgetCash(civ, category, amount);
|
||||
},
|
||||
|
||||
getTraining(cityId) {
|
||||
return this.training.get(cityId) || null;
|
||||
},
|
||||
@@ -762,12 +912,62 @@ export const economyMethods = {
|
||||
this._populationVersion += 1;
|
||||
},
|
||||
|
||||
// The people living in one city's region, summed over its tiles.
|
||||
regionPopulation(city) {
|
||||
let total = 0;
|
||||
for (const coords of this.regionTiles(city)) {
|
||||
total += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// Pulls `amount` people out of a city's region into a unit, choosing the
|
||||
// tiles at random in proportion to how many people each holds. Returns how
|
||||
// many were actually drawn (never more than the region had).
|
||||
drawRegionPopulation(city, amount) {
|
||||
if (!(amount > 0)) return 0;
|
||||
const tiles = [];
|
||||
let total = 0;
|
||||
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 0;
|
||||
const wanted = Math.min(amount, total);
|
||||
let remaining = wanted;
|
||||
while (remaining > 0 && tiles.length > 0) {
|
||||
let pick = this._random() * total;
|
||||
let index = 0;
|
||||
for (; index < tiles.length - 1; index++) {
|
||||
pick -= tiles[index].population;
|
||||
if (pick <= 0) break;
|
||||
}
|
||||
const tile = tiles[index];
|
||||
const take = Math.min(tile.population, remaining);
|
||||
tile.population -= take;
|
||||
total -= take;
|
||||
remaining -= take;
|
||||
this.tilePopulation.set(tile.k, tile.population);
|
||||
if (tile.population <= 0) tiles.splice(index, 1);
|
||||
}
|
||||
this._populationVersion += 1;
|
||||
return wanted;
|
||||
},
|
||||
|
||||
_tickEconomy() {
|
||||
const hours = HOURS_PER_DAY;
|
||||
for (const civ of Array.from(this.budgets.keys())) {
|
||||
const income = this.getCivHourlyIncome(civ) * hours;
|
||||
const upkeep = this.getPlayerUpkeep(civ) * hours;
|
||||
this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
|
||||
// Tax is collected from the private sector, not minted, then the day's
|
||||
// propaganda spending is paid back out to it.
|
||||
this._chargeRegions(civ, income);
|
||||
this.budgets.set(civ, this.getBudget(civ) + income);
|
||||
this._recordBudgetCash(civ, "income", income);
|
||||
if (upkeep > 0) this._spendBudget(civ, upkeep, "campaign");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -777,7 +977,12 @@ export const economyMethods = {
|
||||
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
|
||||
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
|
||||
if (scienceRate === 0 && cultureRate === 0) continue;
|
||||
this._addResearch(civ, scienceRate);
|
||||
// Science buildings spend a unit of high-tech for each research point
|
||||
// they make, so a nation short of chips researches only as far as its
|
||||
// store reaches. Government research needs no high-tech.
|
||||
const buildingRate = this.getCivBuildingModifiers(civ)[EFFECT_RESEARCH] || 0;
|
||||
const effectiveBuilding = this._spendHighTechForResearch(civ, buildingRate);
|
||||
this._addResearch(civ, scienceRate - buildingRate + effectiveBuilding);
|
||||
this.culture.set(civ, this.getCulture(civ) + cultureRate);
|
||||
}
|
||||
},
|
||||
@@ -877,13 +1082,55 @@ export const economyMethods = {
|
||||
},
|
||||
|
||||
// Units resting inside one of their own cities recover a little health each
|
||||
// in-game hour.
|
||||
// in-game hour, at a materials cost -- steel for a ground or naval unit, plus
|
||||
// high-tech for an aircraft or radar -- and by drawing replacement soldiers
|
||||
// out of the region's civilians. A nation that can find neither does not heal.
|
||||
_tickGarrisonHealing() {
|
||||
for (const unit of this.units) {
|
||||
if (unit.hp >= unit.maxHp) continue;
|
||||
const city = this.cityAt(unit.coords);
|
||||
if (!city || city.civ !== unit.civ) continue;
|
||||
unit.hp = Math.min(unit.maxHp, unit.hp + CITY_GARRISON_HEAL_PER_HOUR);
|
||||
const proto = this.unitProto(unit);
|
||||
if (!proto) continue;
|
||||
const heal = Math.min(unit.maxHp - unit.hp, CITY_GARRISON_HEAL_PER_HOUR);
|
||||
if (heal <= 0) continue;
|
||||
// Each hit point restored is a share of the unit's soldiers made good from
|
||||
// the city's people; without enough people, only part of the heal happens.
|
||||
const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
|
||||
const soldierNeed = soldiers > 0 ? (heal / unit.maxHp) * soldiers : 0;
|
||||
const available = this.regionPopulation(city);
|
||||
const popFraction = soldierNeed > 0 ? Math.min(1, available / soldierNeed) : 1;
|
||||
const targetHeal = heal * popFraction;
|
||||
if (targetHeal <= 0) continue;
|
||||
const repair = RESOURCE_RULES.repair;
|
||||
const wanted = {
|
||||
steel: targetHeal * repair.unitSteelPerHp,
|
||||
hightech: proto.advanced ? targetHeal * repair.unitHighTechPerHp : 0,
|
||||
};
|
||||
const fromStock = this._drawFromNearest(city.civ, city.coords, wanted);
|
||||
const shortfall = {
|
||||
steel: wanted.steel - fromStock.steel,
|
||||
hightech: wanted.hightech - fromStock.hightech,
|
||||
};
|
||||
const cost = this._marketUnitCost("steel") * shortfall.steel +
|
||||
this._marketUnitCost("hightech") * shortfall.hightech;
|
||||
const steelCost = this._marketUnitCost("steel") * shortfall.steel;
|
||||
const techCost = this._marketUnitCost("hightech") * shortfall.hightech;
|
||||
const paid = 1;
|
||||
if (cost > 0) {
|
||||
// The government can always pay, borrowing if the treasury is dry.
|
||||
this._spendBudget(city.civ, cost * paid, "units");
|
||||
this._recordResourceSpend(city.civ, "upkeep", cost * paid, {
|
||||
steel: steelCost * paid,
|
||||
hightech: techCost * paid,
|
||||
});
|
||||
}
|
||||
const gained = targetHeal * paid;
|
||||
if (gained > 0) {
|
||||
unit.hp = Math.min(unit.maxHp, unit.hp + gained);
|
||||
const drawn = soldiers > 0 ? (gained / unit.maxHp) * soldiers : 0;
|
||||
if (drawn > 0) this.drawRegionPopulation(city, drawn);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// Money and currencies. Each nation issues its own currency; a central bank
|
||||
// sets an interest rate and holds foreign reserves. The private sector is the
|
||||
// regions: every region keeps its own cash reserve, a basket of currencies, and
|
||||
// chases profit trading with its neighbours.
|
||||
//
|
||||
// Stage 1 keeps every currency at par (value 1 against the global unit) and
|
||||
// every region's cash in its own national currency. Later stages let the rates
|
||||
// float, fill the baskets with foreign money and move real trade onto private
|
||||
// cash; the shapes here (baskets, reserves, rates) are already the final ones.
|
||||
|
||||
import { MONEY } from "../data/economy.js";
|
||||
import { DAYS_PER_YEAR } from "./constants.js";
|
||||
|
||||
const FALLBACK_CURRENCY = { code: "XAU", symbol: "¤", name: "Credit" };
|
||||
|
||||
function emptyBasket() {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
export const moneyMethods = {
|
||||
// ------------------------------------------------------- setup --
|
||||
|
||||
_initMoney() {
|
||||
this.regionCash = new Map();
|
||||
// What each region owes its national central bank, in the national currency.
|
||||
this.regionDebt = new Map();
|
||||
// What each nation's central bank owes foreign central banks, by currency:
|
||||
// the offsetting side of the currency it issues when it settles trade in a
|
||||
// foreign seller's money.
|
||||
this.centralBankDebt = new Map();
|
||||
this.centralBankRate = new Map();
|
||||
this.centralBankReserves = new Map();
|
||||
this.currencyValues = new Map();
|
||||
this._currencyDemand = new Map();
|
||||
this._currencyCivMap = null;
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
this.centralBankRate.set(civ, MONEY.defaultInterestRate);
|
||||
this.centralBankReserves.set(civ, emptyBasket());
|
||||
this.centralBankDebt.set(civ, emptyBasket());
|
||||
this.currencyValues.set(civ, 1);
|
||||
this._currencyDemand.set(civ, 0);
|
||||
}
|
||||
for (const city of this.cities) {
|
||||
this.regionCash.set(city.id, this._openingRegionBasket(city));
|
||||
}
|
||||
},
|
||||
|
||||
// A region opens with a private-sector cash reserve in its nation's currency,
|
||||
// sized off its yearly GDP.
|
||||
_openingRegionBasket(city) {
|
||||
const economy = this.getCityEconomy(city);
|
||||
const cash = Math.max(0, economy.gdp) * MONEY.privateCashFraction;
|
||||
return new Map([[this.currencyOf(city.civ).code, cash]]);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------- currencies --
|
||||
|
||||
currencyOf(civ) {
|
||||
const civEntry = this.civilisations[civ];
|
||||
return (civEntry && civEntry.currency) || FALLBACK_CURRENCY;
|
||||
},
|
||||
|
||||
// The price of one unit of a nation's currency in global units. Every
|
||||
// currency starts at par and floats from there.
|
||||
currencyValue(civ) {
|
||||
if (!this.currencyValues) return 1;
|
||||
return this.currencyValues.get(civ) || 1;
|
||||
},
|
||||
|
||||
setCurrencyValue(civ, value) {
|
||||
const floor = 1 - MONEY.maxRateDrift;
|
||||
const ceiling = 1 + MONEY.maxRateDrift;
|
||||
this.currencyValues.set(civ, Math.max(floor, Math.min(ceiling, value)));
|
||||
},
|
||||
|
||||
_civByCurrencyCode() {
|
||||
if (!this._currencyCivMap) {
|
||||
this._currencyCivMap = new Map();
|
||||
this.civilisations.forEach((entry, index) => {
|
||||
if (entry.currency) this._currencyCivMap.set(entry.currency.code, index);
|
||||
});
|
||||
}
|
||||
return this._currencyCivMap;
|
||||
},
|
||||
|
||||
currencyValueOf(code) {
|
||||
const civ = this._civByCurrencyCode().get(code);
|
||||
return civ === undefined ? 1 : this.currencyValue(civ);
|
||||
},
|
||||
|
||||
// How many units of `toCiv`'s currency one unit of `fromCiv`'s buys.
|
||||
exchangeRate(fromCiv, toCiv) {
|
||||
if (fromCiv === toCiv) return 1;
|
||||
return this.currencyValue(fromCiv) / this.currencyValue(toCiv);
|
||||
},
|
||||
|
||||
// --------------------------------------------------- region cash --
|
||||
|
||||
// Takes `amount` of one currency out of a region's basket. Returns false (and
|
||||
// charges nothing) when the region does not hold enough.
|
||||
_debitRegionCash(city, code, amount) {
|
||||
if (!(amount > 0)) return true;
|
||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
||||
if (!basket) return false;
|
||||
const held = basket.get(code) || 0;
|
||||
if (held + 1e-9 < amount) return false;
|
||||
basket.set(code, held - amount);
|
||||
return true;
|
||||
},
|
||||
|
||||
_creditRegionCash(city, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
||||
if (!basket) return;
|
||||
basket.set(code, (basket.get(code) || 0) + amount);
|
||||
},
|
||||
|
||||
// Charges a region, spending its cash and borrowing whatever is missing from
|
||||
// its national central bank at the bank's rate. A region never fails to pay:
|
||||
// it takes on debt instead.
|
||||
_chargeRegionCash(city, code, amount) {
|
||||
if (!(amount > 0)) return true;
|
||||
const basket = this.regionCash ? this.regionCash.get(city.id) : null;
|
||||
if (!basket) return false;
|
||||
const held = basket.get(code) || 0;
|
||||
if (held >= amount) {
|
||||
basket.set(code, held - amount);
|
||||
return true;
|
||||
}
|
||||
basket.set(code, 0);
|
||||
const shortfall = amount - held;
|
||||
this.regionDebt.set(city.id, (this.regionDebt.get(city.id) || 0) + shortfall);
|
||||
return true;
|
||||
},
|
||||
|
||||
// Pays money into a nation's regions, split by population: the private sector
|
||||
// is what ultimately receives government spending, so the money stays in the
|
||||
// system rather than vanishing.
|
||||
_creditRegions(civ, amount, code = null) {
|
||||
if (!(amount > 0)) return;
|
||||
const cities = this.cities.filter((city) => city.civ === civ);
|
||||
if (cities.length === 0) return;
|
||||
const currency = code || this.currencyOf(civ).code;
|
||||
const shares = this._populationShares(cities);
|
||||
for (let i = 0; i < cities.length; i++) {
|
||||
this._creditRegionCash(cities[i], currency, amount * shares[i]);
|
||||
}
|
||||
},
|
||||
|
||||
// Takes money from a nation's regions, split by population, borrowing from the
|
||||
// central bank where a region is short. This is how taxes are collected
|
||||
// without minting money.
|
||||
_chargeRegions(civ, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const cities = this.cities.filter((city) => city.civ === civ);
|
||||
if (cities.length === 0) return;
|
||||
const currency = this.currencyOf(civ).code;
|
||||
const shares = this._populationShares(cities);
|
||||
for (let i = 0; i < cities.length; i++) {
|
||||
this._chargeRegionCash(cities[i], currency, amount * shares[i]);
|
||||
}
|
||||
},
|
||||
|
||||
// Pays the world market's producers -- every region outside the buyer's nation
|
||||
// -- so an import does not drain money out of the world.
|
||||
_payWorld(buyerCiv, amount, code) {
|
||||
if (!(amount > 0)) return;
|
||||
const others = this.cities.filter((city) => city.civ !== buyerCiv);
|
||||
if (others.length === 0) {
|
||||
this._creditRegions(buyerCiv, amount, code);
|
||||
return;
|
||||
}
|
||||
const shares = this._populationShares(others);
|
||||
for (let i = 0; i < others.length; i++) {
|
||||
this._creditRegionCash(others[i], code, amount * shares[i]);
|
||||
}
|
||||
},
|
||||
|
||||
// Each region's share of a set's total population, defaulting to an even split
|
||||
// when there are no people at all.
|
||||
_populationShares(cities) {
|
||||
let total = 0;
|
||||
const populations = cities.map((city) => {
|
||||
const people = this.regionPopulation(city);
|
||||
total += people;
|
||||
return people;
|
||||
});
|
||||
if (total <= 0) return cities.map(() => 1 / cities.length);
|
||||
return populations.map((people) => people / total);
|
||||
},
|
||||
|
||||
getRegionDebt(city) {
|
||||
return this.regionDebt ? (this.regionDebt.get(city.id) || 0) : 0;
|
||||
},
|
||||
|
||||
// Everything the nation's regions owe their central bank.
|
||||
getRegionDebtTotal(civ) {
|
||||
let total = 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ === civ) total += this.getRegionDebt(city);
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// The central bank's outstanding loans: the government's overdraft, every
|
||||
// region's borrowing, and what it has issued to settle foreign trade.
|
||||
getCentralBankLoans(civ) {
|
||||
return this.getGovernmentDebt(civ) + this.getRegionDebtTotal(civ) +
|
||||
this._centralBankDebtValue(civ);
|
||||
},
|
||||
|
||||
// The city of `civ` nearest `coords`, so a sale to a resource building can be
|
||||
// paid into the private cash of the region that owns it.
|
||||
_regionCityNear(civ, coords) {
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
const distance = this.topology.tileDistance(city.coords, coords);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = city;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
|
||||
// --------------------------------------------------- central bank --
|
||||
|
||||
_addReserve(civ, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
const reserves = this.centralBankReserves.get(civ)
|
||||
|| (this.centralBankReserves.set(civ, emptyBasket()), this.centralBankReserves.get(civ));
|
||||
reserves.set(code, (reserves.get(code) || 0) + amount);
|
||||
},
|
||||
|
||||
// The debt a central bank takes on when it issues its currency to settle a
|
||||
// foreign seller's bill. It is the loan side of the money the sale created, so
|
||||
// the world's money is conserved.
|
||||
_addCentralBankDebt(civ, code, amount) {
|
||||
if (!(amount > 0)) return;
|
||||
let debts = this.centralBankDebt.get(civ);
|
||||
if (!debts) {
|
||||
debts = emptyBasket();
|
||||
this.centralBankDebt.set(civ, debts);
|
||||
}
|
||||
debts.set(code, (debts.get(code) || 0) + amount);
|
||||
},
|
||||
|
||||
getCentralBankDebt(civ) {
|
||||
return this.centralBankDebt ? (this.centralBankDebt.get(civ) || emptyBasket()) : emptyBasket();
|
||||
},
|
||||
|
||||
// What a central bank owes foreign banks, in its own currency.
|
||||
_centralBankDebtValue(civ) {
|
||||
const value = this.currencyValue(civ);
|
||||
let total = 0;
|
||||
for (const [code, amount] of this.getCentralBankDebt(civ)) {
|
||||
total += amount * this.currencyValueOf(code) / value;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// A region buys `globalValue` (in global units) worth of `targetCiv`'s
|
||||
// currency: first from what it already holds, then from that nation's central
|
||||
// bank, paying with its own national currency (borrowing from its own bank if
|
||||
// it is short). The target's central bank keeps the buyer's currency as a
|
||||
// foreign reserve. Returns the amount of the target currency that ends up
|
||||
// handed to the seller.
|
||||
_regionAcquireCurrency(buyerCity, targetCiv, globalValue) {
|
||||
if (!(globalValue > 0)) return { code: this.currencyOf(targetCiv).code, amount: 0 };
|
||||
const target = this.currencyOf(targetCiv);
|
||||
const buyer = this.currencyOf(buyerCity.civ);
|
||||
const vTarget = this.currencyValue(targetCiv);
|
||||
const vBuyer = this.currencyValue(buyerCity.civ);
|
||||
const needTarget = globalValue / vTarget;
|
||||
const basket = this.regionCash.get(buyerCity.id);
|
||||
if (!basket) return null;
|
||||
const heldTarget = basket.get(target.code) || 0;
|
||||
const fromHeld = Math.min(heldTarget, needTarget);
|
||||
const remaining = needTarget - fromHeld;
|
||||
if (remaining > 0) {
|
||||
const costBuyer = remaining * vTarget / vBuyer;
|
||||
// The buyer pays with its national currency, borrowing the rest from its
|
||||
// own central bank if it is short; the seller's bank holds that currency
|
||||
// as a reserve and issues its own, taking on the matching debt so no money
|
||||
// is conjured from nothing.
|
||||
this._chargeRegionCash(buyerCity, buyer.code, costBuyer);
|
||||
this._addReserve(targetCiv, buyer.code, costBuyer);
|
||||
this._addCentralBankDebt(targetCiv, target.code, remaining);
|
||||
}
|
||||
if (fromHeld > 0) basket.set(target.code, heldTarget - fromHeld);
|
||||
return { code: target.code, amount: fromHeld + remaining };
|
||||
},
|
||||
|
||||
// --------------------------------------------------- demand/rates --
|
||||
|
||||
_recordCurrencyDemand(civ, amount) {
|
||||
if (!this._currencyDemand || civ < 0 || !(amount !== 0)) return;
|
||||
this._currencyDemand.set(civ, (this._currencyDemand.get(civ) || 0) + amount);
|
||||
},
|
||||
|
||||
// All of a nation's currency in circulation, in global units: its own
|
||||
// treasury, every region's holding and every central bank's reserve.
|
||||
_currencyMass(civ) {
|
||||
const code = this.currencyOf(civ).code;
|
||||
const value = this.currencyValue(civ);
|
||||
// An overdrawn treasury is a claim on the bank, not circulating money.
|
||||
let amount = Math.max(0, this.getBudget(civ) || 0);
|
||||
for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 0;
|
||||
for (const reserves of this.centralBankReserves.values()) {
|
||||
amount += reserves.get(code) || 0;
|
||||
}
|
||||
return Math.max(1, amount * value);
|
||||
},
|
||||
|
||||
// ----------------------------------------------------- holdings --
|
||||
|
||||
getRegionCash(city) {
|
||||
if (!this.regionCash) return emptyBasket();
|
||||
return this.regionCash.get(city.id) || emptyBasket();
|
||||
},
|
||||
|
||||
getCentralBankReserves(civ) {
|
||||
if (!this.centralBankReserves) return emptyBasket();
|
||||
return this.centralBankReserves.get(civ) || emptyBasket();
|
||||
},
|
||||
|
||||
getCentralBankRate(civ) {
|
||||
if (!this.centralBankRate) return MONEY.defaultInterestRate;
|
||||
const rate = this.centralBankRate.get(civ);
|
||||
return rate === undefined ? MONEY.defaultInterestRate : rate;
|
||||
},
|
||||
|
||||
setCentralBankRate(civ, rate) {
|
||||
const value = Math.max(0, Math.min(MONEY.maxInterestRate, Number(rate) || 0));
|
||||
this.centralBankRate.set(civ, value);
|
||||
},
|
||||
|
||||
// A player sets the central bank's interest rate: the rate regions and the
|
||||
// government borrow at, compounding on their debts.
|
||||
requestSetInterestRate(civ, rate) {
|
||||
if (civ < 0 || civ >= this.civilisations.length) return false;
|
||||
const value = Number(rate);
|
||||
if (!Number.isFinite(value) || value < 0) return false;
|
||||
this.setCentralBankRate(civ, value);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// The value of a basket in `inCiv`'s currency at the current rates.
|
||||
_basketValue(basket, inCiv = 0) {
|
||||
let total = 0;
|
||||
const denom = this.currencyValue(inCiv);
|
||||
for (const [code, amount] of basket) {
|
||||
total += amount * this.currencyValueOf(code) / denom;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// The private-sector cash of one region, expressed in a nation's currency.
|
||||
getRegionCashValue(city, inCiv = city.civ) {
|
||||
return this._basketValue(this.getRegionCash(city), inCiv);
|
||||
},
|
||||
|
||||
// Everything the private sector of a nation holds, in that nation's currency.
|
||||
getPrivateSectorCash(civ, inCiv = civ) {
|
||||
let total = 0;
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
total += this._basketValue(this.getRegionCash(city), inCiv);
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// --------------------------------------------------- daily tick --
|
||||
|
||||
// The day's monetary work: debts to the central bank accrue interest, regions
|
||||
// repatriate any foreign money they still hold and pay down what they owe, and
|
||||
// the day's trading leaves each currency's value drifting with net demand. The
|
||||
// interest rate is the borrowing cost, not a direct inflation lever.
|
||||
_tickMoney() {
|
||||
if (!this.regionCash) return;
|
||||
for (const city of this.cities) {
|
||||
const basket = this.regionCash.get(city.id);
|
||||
const rate = this.getCentralBankRate(city.civ) / DAYS_PER_YEAR;
|
||||
// Interest compounds on whatever the region already owes.
|
||||
const debt = this.regionDebt.get(city.id) || 0;
|
||||
if (debt > 0) this.regionDebt.set(city.id, debt * (1 + rate));
|
||||
if (!basket) continue;
|
||||
this._repatriateRegion(city, basket);
|
||||
this._repayRegionDebt(city, basket);
|
||||
}
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
// A treasury in the red is an overdraft that compounds too.
|
||||
const budget = this.getBudget(civ);
|
||||
if (budget < 0) {
|
||||
const rate = this.getCentralBankRate(civ) / DAYS_PER_YEAR;
|
||||
this.budgets.set(civ, budget * (1 + rate));
|
||||
}
|
||||
const demand = this._currencyDemand.get(civ) || 0;
|
||||
const mass = this._currencyMass(civ);
|
||||
const value = this.currencyValue(civ) * (1 + MONEY.demandSensitivity * (demand / mass));
|
||||
this.setCurrencyValue(civ, value);
|
||||
this._currencyDemand.set(civ, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// A region uses any cash it has to pay down its central-bank debt, so the
|
||||
// interest does not run away.
|
||||
_repayRegionDebt(city, basket) {
|
||||
const debt = this.regionDebt.get(city.id) || 0;
|
||||
if (!(debt > 0)) return;
|
||||
const code = this.currencyOf(city.civ).code;
|
||||
const cash = basket.get(code) || 0;
|
||||
const repay = Math.min(cash, debt);
|
||||
if (!(repay > 0)) return;
|
||||
basket.set(code, cash - repay);
|
||||
this.regionDebt.set(city.id, debt - repay);
|
||||
},
|
||||
|
||||
// A region turns a slice of every foreign currency it holds back into its own;
|
||||
// the private sector prefers to carry national money. The conversion is priced
|
||||
// through the global desk at today's rates.
|
||||
_repatriateRegion(city, basket) {
|
||||
const national = this.currencyOf(city.civ).code;
|
||||
let changed = false;
|
||||
for (const [code, amount] of Array.from(basket)) {
|
||||
if (code === national || !(amount > 0)) continue;
|
||||
const move = amount * MONEY.repatriationPerDay;
|
||||
if (!(move > 0)) continue;
|
||||
const value = move * this.currencyValueOf(code);
|
||||
basket.set(code, amount - move);
|
||||
const issuer = this._civByCurrencyCode().get(code);
|
||||
if (issuer !== undefined) this._recordCurrencyDemand(issuer, -value);
|
||||
this._recordCurrencyDemand(city.civ, value);
|
||||
this._creditRegionCash(city, national, value / this.currencyValue(city.civ));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) this.regionCash.set(city.id, basket);
|
||||
},
|
||||
|
||||
// ------------------------------------------------- serialization --
|
||||
|
||||
_serializeCurrencies() {
|
||||
return this.civilisations.map((_, civ) => {
|
||||
const currency = this.currencyOf(civ);
|
||||
return {
|
||||
civ,
|
||||
code: currency.code,
|
||||
symbol: currency.symbol,
|
||||
name: currency.name,
|
||||
value: this.currencyValue(civ),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// The exchange rate from the viewer's currency to every other nation's, for
|
||||
// the national Economy tab.
|
||||
_serializeExchangeRates(viewerCiv) {
|
||||
return this.civilisations.map((_, civ) => {
|
||||
const currency = this.currencyOf(civ);
|
||||
return {
|
||||
civ,
|
||||
code: currency.code,
|
||||
symbol: currency.symbol,
|
||||
name: currency.name,
|
||||
rate: this.exchangeRate(viewerCiv, civ),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_serializeCentralBank(civ) {
|
||||
const reserves = [];
|
||||
for (const [code, amount] of this.getCentralBankReserves(civ)) {
|
||||
if (!(amount > 0)) continue;
|
||||
const issuer = this._civByCurrencyCode().get(code);
|
||||
const currency = issuer === undefined ? FALLBACK_CURRENCY : this.currencyOf(issuer);
|
||||
reserves.push({ code, symbol: currency.symbol, name: currency.name, amount });
|
||||
}
|
||||
return {
|
||||
rate: this.getCentralBankRate(civ),
|
||||
currency: this.currencyOf(civ),
|
||||
reserves,
|
||||
// What the bank has lent: the government's overdraft plus region borrowing.
|
||||
loans: this.getCentralBankLoans(civ),
|
||||
};
|
||||
},
|
||||
|
||||
_serializeRegionCash(city) {
|
||||
const cash = {};
|
||||
for (const [code, amount] of this.getRegionCash(city)) {
|
||||
if (amount > 0) cash[code] = amount;
|
||||
}
|
||||
return cash;
|
||||
},
|
||||
};
|
||||
@@ -25,12 +25,15 @@ export const monthlyMethods = {
|
||||
// the same month a year earlier. A rolling 13 months is enough to always
|
||||
// hold the twelve-months-ago baseline.
|
||||
this._monthStartHistory = [{ index: this._monthIndexNow, stats: this._currentMonthStats }];
|
||||
// The monthly budget report starts its first ledger here too.
|
||||
this._initBudgetHistory();
|
||||
},
|
||||
|
||||
// Called every hour; only a calendar-month change does any work.
|
||||
_tickMonthly() {
|
||||
const index = this._monthIndexOf(this.totalHours);
|
||||
if (index === this._monthIndexNow) return;
|
||||
const previous = this._monthIndexNow;
|
||||
// What was current at the start of the month that just ended is now the
|
||||
// baseline for the new month.
|
||||
this.monthlyStats = this._currentMonthStats;
|
||||
@@ -38,6 +41,8 @@ export const monthlyMethods = {
|
||||
this._monthIndexNow = index;
|
||||
this._monthStartHistory.push({ index, stats: this._currentMonthStats });
|
||||
if (this._monthStartHistory.length > 13) this._monthStartHistory.shift();
|
||||
// The month that just ended becomes a browsable budget digest.
|
||||
this._rolloverBudget(previous);
|
||||
},
|
||||
|
||||
// The relative growth of a nation's headline figures against the same month a
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GOVERNMENTS } from "../data/governments.js";
|
||||
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
||||
import { transportImprovement } from "../data/improvements.js";
|
||||
import { buildingBuildCost, buildingRefund } from "../rules.js";
|
||||
import { tileImprovementResourceCost } from "../resources.js";
|
||||
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||
|
||||
export const orderMethods = {
|
||||
@@ -39,7 +40,12 @@ export const orderMethods = {
|
||||
}
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
||||
if (capacity <= 0) return false;
|
||||
if (!this._spendBudget(city.civ, proto.cost)) 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")) {
|
||||
this._refundBudget(city.civ, proto.cost, "training");
|
||||
return false;
|
||||
}
|
||||
const entry = {
|
||||
kind: "unit",
|
||||
protoIndex,
|
||||
@@ -79,6 +85,7 @@ export const orderMethods = {
|
||||
// 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 =
|
||||
@@ -106,7 +113,12 @@ export const orderMethods = {
|
||||
}
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
||||
if (capacity <= 0) return false;
|
||||
if (!this._spendBudget(city.civ, cost)) 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)) {
|
||||
this._refundBudget(city.civ, cost, "construction");
|
||||
return false;
|
||||
}
|
||||
const entry = {
|
||||
kind: "building",
|
||||
protoIndex,
|
||||
@@ -139,7 +151,14 @@ export const orderMethods = {
|
||||
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 && !this._spendBudget(civ, improvement.buildCost)) return false;
|
||||
if (!free) {
|
||||
if (!this._spendBudget(civ, improvement.buildCost, "tilebuild")) return false;
|
||||
const materials = tileImprovementResourceCost(improvement);
|
||||
if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) {
|
||||
this._refundBudget(civ, improvement.buildCost, "tilebuild");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
if (improvement.id === "railway") this.railways.add(k);
|
||||
@@ -177,7 +196,7 @@ export const orderMethods = {
|
||||
const level = this.getCityBuildingLevel(city, protoIndex);
|
||||
if (level <= 0) return false;
|
||||
const refund = buildingRefund(proto, level);
|
||||
if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund);
|
||||
if (refund > 0) this._refundBudget(civ, refund, "construction");
|
||||
if (level <= 1) delete city.buildings[protoIndex];
|
||||
else city.buildings[protoIndex] = level - 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
@@ -192,8 +211,7 @@ export const orderMethods = {
|
||||
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.getBudget(civ) < cost) return false;
|
||||
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
|
||||
if (cost > 0) this._spendBudget(civ, cost, "campaign");
|
||||
this.government.set(civ, index);
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._touchModifiers();
|
||||
@@ -232,8 +250,8 @@ export const orderMethods = {
|
||||
|
||||
// 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 GDP per capita by 30% and heals one
|
||||
// hit point per 1000 people it drives off.
|
||||
// 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;
|
||||
@@ -243,10 +261,12 @@ export const orderMethods = {
|
||||
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.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1;
|
||||
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.gdpPenalty;
|
||||
penalty *= PILLAGE.productionPenalty;
|
||||
const unit = ordered[i];
|
||||
unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal);
|
||||
drivenOff += steps[i].removed;
|
||||
@@ -254,7 +274,7 @@ export const orderMethods = {
|
||||
population = steps.length > 0 ? steps[steps.length - 1].remaining : population;
|
||||
this.tilePopulation.set(k, population);
|
||||
this._populationVersion += 1;
|
||||
this.tileGdpPenalty.set(k, penalty);
|
||||
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.
|
||||
|
||||
@@ -61,6 +61,7 @@ export const politicsMethods = {
|
||||
_tickPolitics() {
|
||||
this._tickCampaigns();
|
||||
this._tickMigration();
|
||||
this._migrateForIncome();
|
||||
this._tickUprisings();
|
||||
this._tickCultureImpact();
|
||||
this._expireGrudges();
|
||||
@@ -672,6 +673,280 @@ export const politicsMethods = {
|
||||
this._migrateByAir();
|
||||
},
|
||||
|
||||
// ------------------------------------------------- income migration --
|
||||
|
||||
// People drift from poorer regions to richer ones, but only to places clearly
|
||||
// richer: a destination must be at least `incomeRatioBase` times the source's
|
||||
// income per head, and the bar rises the longer the journey takes. A flight
|
||||
// crosses the distance in a flat few hours, but each airport can only move so
|
||||
// many people a day.
|
||||
_migrateForIncome() {
|
||||
if (this.cities.length < 2) return;
|
||||
const income = this._regionIncomePerCapita(this.cities);
|
||||
// Each airport's daily intake, shared among every region that flies into it.
|
||||
const airBudget = new Map();
|
||||
for (const city of this.cities) {
|
||||
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_IMMIGRATION);
|
||||
if (level > 0) airBudget.set(city.id, MIGRATION.airPeoplePerDayPerLevel * level);
|
||||
}
|
||||
for (const from of this.cities) {
|
||||
const fromIncome = income.get(from.id) || 0;
|
||||
if (fromIncome <= 0) continue;
|
||||
let source = this.regionPopulation(from) - MIGRATION.minRegionPopulation;
|
||||
if (source <= 0) continue;
|
||||
// The time bar only ever raises the requirement above the base ratio, so a
|
||||
// region with no destination that much richer has nowhere to send anyone.
|
||||
// Skip the route search entirely -- the common, balanced case.
|
||||
const worthwhile = this.cities.some((other) =>
|
||||
other !== from &&
|
||||
(income.get(other.id) || 0) >= fromIncome * MIGRATION.incomeRatioBase
|
||||
);
|
||||
if (!worthwhile) continue;
|
||||
const fromAirport = this._cityMechanicLevel(from, BUILDING_MECHANIC.AIR_IMMIGRATION);
|
||||
const travel = this._migrationTravel(from);
|
||||
for (const to of this.cities) {
|
||||
if (to === from || source <= 0) continue;
|
||||
const toIncome = income.get(to.id) || 0;
|
||||
if (toIncome <= 0) continue;
|
||||
// Two nations at war do not trade people.
|
||||
if (this.isAtWar(from.civ, to.civ)) continue;
|
||||
const toAirport = this._cityMechanicLevel(to, BUILDING_MECHANIC.AIR_IMMIGRATION);
|
||||
const air = fromAirport > 0 && toAirport > 0;
|
||||
const hours = air ? MIGRATION.airHours : travel.get(to.id);
|
||||
if (hours === undefined) continue;
|
||||
// The longer it takes, the richer the destination has to be.
|
||||
const required = MIGRATION.incomeRatioBase * (1 + hours * MIGRATION.incomeTimePenalty);
|
||||
const ratio = toIncome / fromIncome;
|
||||
if (ratio < required) continue;
|
||||
let amount;
|
||||
if (air) {
|
||||
const budget = airBudget.get(to.id) || 0;
|
||||
amount = Math.min(budget, source);
|
||||
if (amount <= 0) continue;
|
||||
airBudget.set(to.id, budget - amount);
|
||||
} else {
|
||||
const rate = Math.min(
|
||||
MIGRATION.incomeMaxSharePerDay,
|
||||
MIGRATION.incomeSharePerDay * Math.min(ratio - required, MIGRATION.incomeGapCap)
|
||||
);
|
||||
amount = source * rate;
|
||||
}
|
||||
amount = Math.min(amount, source);
|
||||
if (!(amount > 0)) continue;
|
||||
this._movePopulation(from, to, amount);
|
||||
source -= amount;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// The travel time in hours from one region to every city it can reach over the
|
||||
// transport network, capped at `maxTravelHours`. Land is walked (roads and
|
||||
// railways are faster), open water is crossed at the sea rate.
|
||||
_migrationTravel(fromCity) {
|
||||
// Travel times depend only on the transport network, so they are kept until
|
||||
// a road, railway or port changes it.
|
||||
if (!this._travelCache) this._travelCache = new Map();
|
||||
const cached = this._travelCache.get(fromCity.id);
|
||||
if (cached && cached.version === this._improvementVersion) return cached.result;
|
||||
const hours = new Map();
|
||||
const result = new Map();
|
||||
const startKey = key(fromCity.coords.x, fromCity.coords.y);
|
||||
hours.set(startKey, 0);
|
||||
const queue = [fromCity.coords];
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const coords = queue[head++];
|
||||
const k = key(coords.x, coords.y);
|
||||
const here = hours.get(k) || 0;
|
||||
const city = this.cityAt(coords);
|
||||
if (city && city !== fromCity) result.set(city.id, here);
|
||||
if (here >= MIGRATION.maxTravelHours) continue;
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (hours.has(nk) || !this.tiles[nk]) continue;
|
||||
const step = this._peopleHoursPerTile(neighbour);
|
||||
if (!Number.isFinite(step)) continue;
|
||||
hours.set(nk, here + step);
|
||||
queue.push(neighbour);
|
||||
}
|
||||
}
|
||||
this._travelCache.set(fromCity.id, { version: this._improvementVersion, result });
|
||||
return result;
|
||||
},
|
||||
|
||||
// What one tile costs a migrant in hours: open water is a flat sea rate, land
|
||||
// is the terrain (or road/railway) multiplier over the walking speed.
|
||||
_peopleHoursPerTile(coords) {
|
||||
const tile = this.tiles[key(coords.x, coords.y)];
|
||||
if (!tile) return Infinity;
|
||||
if (tile.terrainClass === "Sea" || tile.terrainClass === "Ice") {
|
||||
return MIGRATION.seaHoursPerTile;
|
||||
}
|
||||
return this._tileMovementMultiplier(coords, -1) / MIGRATION.peopleSpeed;
|
||||
},
|
||||
|
||||
// The production per head of every region, as the economy shows it. The
|
||||
// nation-wide balance is folded in here too, so two regions in different
|
||||
// countries compare on the same footing.
|
||||
_regionIncomePerCapita(cities) {
|
||||
const result = new Map();
|
||||
for (const city of cities) {
|
||||
const economy = this.getCityEconomy(city);
|
||||
result.set(city.id, economy.population > 0 ? economy.gdp / economy.population : 0);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// Moves `amount` people from one region to another, carrying their ethnic
|
||||
// make-up with them. Source tiles lose people in proportion to what they hold
|
||||
// and destination tiles gain the arrivals the same way, so a region's mix of
|
||||
// terrain is preserved and only its density changes. Returns how many moved.
|
||||
_movePopulation(fromCity, toCity, amount, record = true) {
|
||||
if (!(amount > 0) || fromCity === toCity) return 0;
|
||||
const source = this.regionTiles(fromCity)
|
||||
.map((coords) => ({ coords, k: key(coords.x, coords.y) }))
|
||||
.filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
|
||||
const dest = this.regionTiles(toCity)
|
||||
.map((coords) => ({ coords, k: key(coords.x, coords.y) }))
|
||||
.filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
|
||||
if (source.length === 0 || dest.length === 0) return 0;
|
||||
const totalFrom = source.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
|
||||
if (totalFrom <= 0) return 0;
|
||||
const moved = Math.min(amount, totalFrom);
|
||||
if (!(moved > 0)) return 0;
|
||||
|
||||
// The aggregate ethnic mix of the people leaving.
|
||||
const mix = new Map();
|
||||
for (const entry of source) {
|
||||
const people = this.tilePopulation.get(entry.k) || 0;
|
||||
const shares = this.tileEthnicity.get(entry.k);
|
||||
if (!shares) continue;
|
||||
for (const [eth, share] of shares) mix.set(eth, (mix.get(eth) || 0) + share * people);
|
||||
}
|
||||
const totalMix = Array.from(mix.values()).reduce((sum, value) => sum + value, 0) || 1;
|
||||
for (const [eth, value] of mix) mix.set(eth, value / totalMix);
|
||||
|
||||
for (const entry of source) {
|
||||
const people = this.tilePopulation.get(entry.k) || 0;
|
||||
this.tilePopulation.set(entry.k, people - moved * people / totalFrom);
|
||||
}
|
||||
const totalTo = dest.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
|
||||
for (const entry of dest) {
|
||||
const people = this.tilePopulation.get(entry.k) || 0;
|
||||
const add = totalTo > 0 ? moved * people / totalTo : moved / dest.length;
|
||||
const after = people + add;
|
||||
this.tilePopulation.set(entry.k, after);
|
||||
const shares = new Map(this.tileEthnicity.get(entry.k) || []);
|
||||
for (const [eth, share] of mix) {
|
||||
const existing = shares.get(eth) || 0;
|
||||
shares.set(eth, (existing * people + share * add) / after);
|
||||
}
|
||||
this._setTileEthnicFractions(parseKey(entry.k), shares);
|
||||
}
|
||||
this._populationVersion += 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
// Keep each city's headline population in step with its city tile.
|
||||
for (const city of [fromCity, toCity]) {
|
||||
city.population = Math.round(
|
||||
this.tilePopulation.get(key(city.coords.x, city.coords.y)) || 0
|
||||
);
|
||||
}
|
||||
if (record) {
|
||||
for (const [eth, share] of mix) {
|
||||
const people = moved * share;
|
||||
this._recordMigration(fromCity.civ, "out", eth, people);
|
||||
this._recordMigration(toCity.civ, "in", eth, people);
|
||||
this._trackMigration(
|
||||
key(fromCity.coords.x, fromCity.coords.y),
|
||||
key(toCity.coords.x, toCity.coords.y),
|
||||
eth,
|
||||
people
|
||||
);
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
},
|
||||
|
||||
// Start-of-game smoothing: within each nation, redistribute people so every
|
||||
// region has the same production per head. A region's GDP does not depend on
|
||||
// how many people live on its land -- food comes from the land and industry
|
||||
// from its works -- so moving people in proportion leaves every region's GDP
|
||||
// untouched and only changes the divisor. Equal income per head therefore
|
||||
// means population proportional to GDP, which is one exact pass, no iteration.
|
||||
_equalizeRegionIncomes() {
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
const cities = this.cities.filter((city) => city.civ === civ);
|
||||
if (cities.length < 2) continue;
|
||||
// The population-proportional-to-GDP target is exact only while a region's
|
||||
// GDP is independent of its people; flat per-head bonuses (ports, natural
|
||||
// growth) make it depend a little, so a few passes settle it.
|
||||
for (let pass = 0; pass < MIGRATION.equalizePasses; pass++) {
|
||||
const population = new Map();
|
||||
const gdp = new Map();
|
||||
let totalPopulation = 0;
|
||||
let totalGdp = 0;
|
||||
let lowest = Infinity;
|
||||
let highest = 0;
|
||||
for (const city of cities) {
|
||||
const people = this.regionPopulation(city);
|
||||
const product = this.getCityEconomy(city).gdp;
|
||||
population.set(city.id, people);
|
||||
gdp.set(city.id, product);
|
||||
totalPopulation += people;
|
||||
totalGdp += product;
|
||||
if (people > 0) {
|
||||
const perHead = product / people;
|
||||
if (perHead < lowest) lowest = perHead;
|
||||
if (perHead > highest) highest = perHead;
|
||||
}
|
||||
}
|
||||
if (totalGdp <= 0 || totalPopulation <= 0) break;
|
||||
if (highest <= 0 || (highest - lowest) / highest <= MIGRATION.equalizeTolerance) {
|
||||
break;
|
||||
}
|
||||
// The share of the nation's people each region should hold, with a floor
|
||||
// so no region is emptied entirely.
|
||||
let target = cities.map((city) => Math.max(
|
||||
MIGRATION.minRegionPopulation,
|
||||
totalPopulation * (gdp.get(city.id) || 0) / totalGdp
|
||||
));
|
||||
const targetTotal = target.reduce((sum, value) => sum + value, 0);
|
||||
if (targetTotal > totalPopulation) {
|
||||
target = target.map((value) => value * totalPopulation / targetTotal);
|
||||
}
|
||||
const targetByCity = new Map(cities.map((city, index) => [city.id, target[index]]));
|
||||
this._redistributePopulation(cities, population, targetByCity);
|
||||
}
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
},
|
||||
|
||||
// Moves people from the regions holding more than their target to those
|
||||
// holding less, so each ends at its target.
|
||||
_redistributePopulation(cities, population, target) {
|
||||
const surplus = [];
|
||||
const deficit = [];
|
||||
for (const city of cities) {
|
||||
const delta = (population.get(city.id) || 0) - (target.get(city.id) || 0);
|
||||
if (delta > 1) surplus.push({ city, amount: delta });
|
||||
else if (delta < -1) deficit.push({ city, amount: -delta });
|
||||
}
|
||||
surplus.sort((a, b) => b.amount - a.amount);
|
||||
deficit.sort((a, b) => b.amount - a.amount);
|
||||
let index = 0;
|
||||
for (const source of surplus) {
|
||||
let remaining = source.amount;
|
||||
while (remaining > 1 && index < deficit.length) {
|
||||
const sink = deficit[index];
|
||||
const moved = Math.min(remaining, sink.amount);
|
||||
if (moved > 1) this._movePopulation(source.city, sink.city, moved, false);
|
||||
remaining -= moved;
|
||||
sink.amount -= moved;
|
||||
if (sink.amount <= 1) index += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// People fly between airport cities. Every city with an airport draws a small
|
||||
// share of every other airport city's people, scaled by its own airport level,
|
||||
// so a busy hub grows at the expense of quieter ones. The move ignores
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { parseKey } from "../hex.js";
|
||||
import { OPINION } from "../data/politics.js";
|
||||
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
|
||||
import { RESOURCE_IDS } from "../data/resources.js";
|
||||
import { serializeSet } from "./helpers.js";
|
||||
|
||||
export const serializationMethods = {
|
||||
@@ -44,16 +45,19 @@ export const serializationMethods = {
|
||||
trenches: this._serializeTrenches(),
|
||||
tileImprovements: this._serializeTileImprovements(),
|
||||
// Each land tile's region: [x, y, cityId], so the browser attributes a
|
||||
// tile's GDP modifiers to the city that develops it.
|
||||
// tile's production modifiers to the city that develops it.
|
||||
regions: this._serializeRegions(),
|
||||
population: this._serializePopulation(),
|
||||
// Every land tile's opening GDP per capita, the constant natural growth
|
||||
// is measured from. Static, so it travels once and is then delta-dropped.
|
||||
gdpBaseline: this._serializeGdpBaseline(),
|
||||
// Every land tile's opening production per head, the constant natural
|
||||
// growth is measured from. Static, so it travels once and is then dropped.
|
||||
productionBaseline: this._serializeProductionBaseline(),
|
||||
// The production per head each region starts from, before the tile
|
||||
// modifiers. The browser mirrors the server's tile breakdown from it.
|
||||
regionProductionBase: this._serializeRegionProductionBase(),
|
||||
// Sparse per-tile marks of war, so the browser can explain a tile's
|
||||
// GDP per capita without shipping the whole breakdown for every tile.
|
||||
tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty),
|
||||
tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit),
|
||||
// production per head without shipping the whole breakdown for every tile.
|
||||
tileProductionPenalties: this._serializeTileValues(this.tileProductionPenalty),
|
||||
tileBattleProductionDeficits: this._serializeTileValues(this.tileBattleProductionDeficit),
|
||||
civStats: this._serializeCivStats(),
|
||||
cityStats: cityStats,
|
||||
budgets: this._serializeCivValues(this.budgets),
|
||||
@@ -87,6 +91,11 @@ export const serializationMethods = {
|
||||
conflicts: this._serializeConflicts(),
|
||||
news: this._serializeNews(),
|
||||
battles: this._serializeBattles(),
|
||||
// The global resource market: today's price and the supply and demand
|
||||
// that moved it, for the resource panel.
|
||||
resourceMarket: this._serializeResourceMarket(),
|
||||
// Every nation's imports and exports of each resource over the last day.
|
||||
resourceTrade: this._serializeResourceTrade(),
|
||||
// Each nation's indicators a month ago, for the summary change arrows;
|
||||
// empty during January 2000, which has no past month.
|
||||
monthly: this.monthlyStats || [],
|
||||
@@ -111,7 +120,8 @@ export const serializationMethods = {
|
||||
warfare: this._tileImprovementVersion,
|
||||
cityStats: hashCityStats(cityStats),
|
||||
visible: this._visibleVersion,
|
||||
gdpBaseline: this._gdpBaselineVersion,
|
||||
productionBaseline: this._productionBaselineVersion,
|
||||
resourceGraph: this._resourceVersion,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -126,6 +136,9 @@ export const serializationMethods = {
|
||||
// an empty queue, so neither the map bar nor the city panel can reveal
|
||||
// whether a foreign city is busy.
|
||||
result.training = trainingForViewer(shared.training, shared.cities, viewerCiv);
|
||||
// The delivery graph is the viewer's own network: its storage nodes and the
|
||||
// legs that carried goods into its cities. Only the economic map reveals it.
|
||||
result.resourceGraph = this._serializeResourceGraph(viewerCiv);
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -136,6 +149,7 @@ export const serializationMethods = {
|
||||
upkeep: 0, researchRate: 0, culture: 0, government: 0,
|
||||
approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
|
||||
constructionSpeed: 0, railSpeed: 0,
|
||||
resources: null,
|
||||
breakdown: null,
|
||||
};
|
||||
}
|
||||
@@ -146,8 +160,13 @@ export const serializationMethods = {
|
||||
// Repeatable research the browser mirrors: construction and rail speed.
|
||||
constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
|
||||
railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
|
||||
// The nation's stores, daily needs and production, plus today's prices.
|
||||
resources: this.getCivResourceSummary(viewerCiv),
|
||||
budget: this.getBudget(viewerCiv),
|
||||
upkeep: this.getPlayerUpkeep(viewerCiv),
|
||||
// The upkeep figure the HUD nets against income: propaganda money plus
|
||||
// the market value of the materials buildings, units and works wear out.
|
||||
upkeep: this.getPlayerUpkeep(viewerCiv) +
|
||||
this._upkeepCost(this.getCivUpkeepResources(viewerCiv)),
|
||||
// The research this nation creates each hour, all of which flows into the
|
||||
// focused technology. There is no stock of points.
|
||||
researchRate: modifiers[EFFECT_RESEARCH] || 0,
|
||||
@@ -158,12 +177,42 @@ export const serializationMethods = {
|
||||
cultureImpact: this.getCulturalImpact(viewerCiv),
|
||||
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
|
||||
breakdown: this.budgetBreakdown(viewerCiv),
|
||||
// The month's budget, plus the last two years of monthly digests, so the
|
||||
// panel can report what has actually been spent rather than a rate.
|
||||
budgetMonths: this._serializeBudgetMonths(viewerCiv),
|
||||
// Money: the nation's currency, everyone else's, the exchange rates the
|
||||
// viewer sees, the central bank's rate and reserves, and the aggregate
|
||||
// private-sector cash its regions hold.
|
||||
currency: this.currencyOf(viewerCiv),
|
||||
currencies: this._serializeCurrencies(),
|
||||
exchangeRates: this._serializeExchangeRates(viewerCiv),
|
||||
centralBank: this._serializeCentralBank(viewerCiv),
|
||||
privateCash: this.getPrivateSectorCash(viewerCiv),
|
||||
governmentDebt: this.getGovernmentDebt(viewerCiv),
|
||||
// How far the headline figures have grown, against January 2000 in the
|
||||
// first year and year-on-year afterwards.
|
||||
growth: this._growthRates(viewerCiv),
|
||||
};
|
||||
},
|
||||
|
||||
// The monthly budget reports, compacted for the wire: category ids replace
|
||||
// the labels the client already knows from the data catalogue.
|
||||
_serializeBudgetMonths(civ) {
|
||||
return this.budgetMonthReports(civ).map((report) => ({
|
||||
monthIndex: report.monthIndex,
|
||||
isCurrent: report.isCurrent,
|
||||
income: report.income,
|
||||
sales: report.sales,
|
||||
totalCash: report.totalCash,
|
||||
totalEconomic: report.totalEconomic,
|
||||
net: report.net,
|
||||
expenses: report.expenses.map(
|
||||
(entry) => [entry.id, entry.cash, entry.economic, entry.buys]
|
||||
),
|
||||
resourceTrade: report.resourceTrade.map((entry) => [entry.id, entry.bought, entry.sold]),
|
||||
}));
|
||||
},
|
||||
|
||||
_serializeCities() {
|
||||
return this.cities.map((city) => ({
|
||||
id: city.id,
|
||||
@@ -207,6 +256,8 @@ export const serializationMethods = {
|
||||
airHours: unit.airHours,
|
||||
airborne: !!unit.airborne,
|
||||
homeCityId: unit.homeCityId,
|
||||
// A land unit's carried food, in days. Zero means it is starving.
|
||||
foodDays: unit.foodDays,
|
||||
// A standing bombardment target (ground battery) or bomb-run target
|
||||
// (aircraft), as [x, y], and the hour an aircraft may strike again.
|
||||
strikeTarget: unit.strikeTarget
|
||||
@@ -298,22 +349,32 @@ export const serializationMethods = {
|
||||
return result;
|
||||
},
|
||||
|
||||
// A sparse [x, y, value] list of every land tile's opening GDP per capita. It
|
||||
// never changes, so it is memoised and the delta wire drops it after the first
|
||||
// snapshot a peer receives.
|
||||
_serializeGdpBaseline() {
|
||||
if (this._serializedGdpBaselineCache) return this._serializedGdpBaselineCache;
|
||||
// A sparse [x, y, value] list of every land tile's opening production per
|
||||
// head. It never changes, so it is memoised and the delta wire drops it after
|
||||
// the first snapshot a peer receives.
|
||||
_serializeProductionBaseline() {
|
||||
if (this._serializedProductionBaselineCache) return this._serializedProductionBaselineCache;
|
||||
const result = [];
|
||||
if (this.tileGdpBaseline) {
|
||||
for (const [k, value] of this.tileGdpBaseline) {
|
||||
if (this.tileProductionBaseline) {
|
||||
for (const [k, value] of this.tileProductionBaseline) {
|
||||
const coords = parseKey(k);
|
||||
result.push([coords.x, coords.y, Math.round(value)]);
|
||||
}
|
||||
}
|
||||
this._serializedGdpBaselineCache = result;
|
||||
this._serializedProductionBaselineCache = result;
|
||||
return result;
|
||||
},
|
||||
|
||||
// Each region's production per head, before its tile modifiers, as
|
||||
// [cityId, value]. Small and world-wide, so every viewer gets it and the tile
|
||||
// panel can explain a tile's figure exactly as the server derived it.
|
||||
_serializeRegionProductionBase() {
|
||||
return this.cities.map((city) => [
|
||||
city.id,
|
||||
roundTo(this._regionProductionPerCapita(city), 4),
|
||||
]);
|
||||
},
|
||||
|
||||
// A sparse [x, y, value] list for a per-tile map, used for the war marks.
|
||||
_serializeTileValues(values) {
|
||||
const result = [];
|
||||
@@ -353,6 +414,11 @@ export const serializationMethods = {
|
||||
income: economy.income,
|
||||
// The region's own income and building upkeep, for the city budget tab.
|
||||
budget: this.cityBudgetBreakdown(city, economy),
|
||||
// The private-sector cash the region holds, a basket of currencies, and
|
||||
// what it owes its central bank.
|
||||
cash: this._serializeRegionCash(city),
|
||||
cashValue: this.getRegionCashValue(city),
|
||||
debt: this.getRegionDebt(city),
|
||||
// The ethnicity of the region's people, for the circle graph.
|
||||
ethnicMakeup: this.getCityEthnicMakeup(city),
|
||||
};
|
||||
@@ -511,6 +577,65 @@ export const serializationMethods = {
|
||||
return civs.includes(owner) ? owner : civs[0];
|
||||
},
|
||||
|
||||
// -------------------------------------------------------- resources --
|
||||
|
||||
// Today's global price and the supply and demand behind it, one entry per
|
||||
// resource. The figures are world-wide, so every viewer sees the same market.
|
||||
_serializeResourceMarket() {
|
||||
return RESOURCE_IDS.map((id) => {
|
||||
const stats = this.resourceMarketStats ? this.resourceMarketStats.get(id) : null;
|
||||
return {
|
||||
id,
|
||||
price: this.getResourcePrice(id),
|
||||
supply: stats ? stats.supply : 0,
|
||||
demand: stats ? stats.demand : 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Every nation's imports and exports of each resource over the last day, so
|
||||
// the Resources tab can show a viewer's own trade and rank the world's major
|
||||
// exporters and importers. Cross-nation deliveries only.
|
||||
_serializeResourceTrade() {
|
||||
return this.civilisations.map((_, civ) => {
|
||||
const record = this.resourceTrade ? this.resourceTrade.get(civ) : null;
|
||||
const imports = {};
|
||||
const exports = {};
|
||||
for (const id of RESOURCE_IDS) {
|
||||
imports[id] = roundTo(record ? record.imports[id] || 0 : 0, 3);
|
||||
exports[id] = roundTo(record ? record.exports[id] || 0 : 0, 3);
|
||||
}
|
||||
return { civ, imports, exports };
|
||||
});
|
||||
},
|
||||
|
||||
// The viewer's own storage nodes and delivery legs, as [x, y] nodes and
|
||||
// [fromX, fromY, toX, toY, resource, amount] links. A spectator sees none.
|
||||
_serializeResourceGraph(viewerCiv) {
|
||||
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [] };
|
||||
const nodes = [];
|
||||
for (const [k] of this.resourceStock) {
|
||||
const coords = parseKey(k);
|
||||
const node = this._resourceNodeAt(coords);
|
||||
if (!node || node.civ !== viewerCiv) continue;
|
||||
nodes.push([coords.x, coords.y, node.kind === "city" ? 1 : 0]);
|
||||
}
|
||||
const links = [];
|
||||
for (const link of this.resourceLinks || []) {
|
||||
const to = link.to;
|
||||
if (this.civAt({ x: to[0], y: to[1] }) !== viewerCiv) continue;
|
||||
links.push([
|
||||
link.from[0],
|
||||
link.from[1],
|
||||
link.to[0],
|
||||
link.to[1],
|
||||
link.resource,
|
||||
Math.round(link.amount * 1000) / 1000,
|
||||
]);
|
||||
}
|
||||
return { nodes, links };
|
||||
},
|
||||
|
||||
// --------------------------------------------------------- politics --
|
||||
|
||||
// Every nation's share of world culture, as a fraction per civilisation.
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
// Supply: encirclement and siege detection, including the maritime exception
|
||||
// that keeps a coastal city supplied through allied naval units.
|
||||
// Supply: encirclement and siege detection. A tile is cut off when enemy units
|
||||
// sit on every land tile beside it and it has no working port to bring supplies
|
||||
// in by sea; a port is itself blockaded when enemy warships hold every sea tile
|
||||
// around its city.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { STATUS_ENCIRCLED, STATUS_BESIEGED } from "../data/statuses.js";
|
||||
|
||||
export const statusMethods = {
|
||||
// Recomputes the status of every unit and city. The supply search only runs
|
||||
// for an occupant that has a hostile unit adjacent, and only once a day (and
|
||||
// at setup), so it never touches the snapshot path.
|
||||
// Recomputes the status of every unit and city. A unit cut off is encircled;
|
||||
// a city cut off is besieged. The check is local -- the ring of land tiles
|
||||
// around the occupant and its own port -- so it never walks the whole map.
|
||||
_refreshStatuses() {
|
||||
const components = new Map();
|
||||
for (const unit of this.units) {
|
||||
if (!this._hasAdjacentHostile(unit.coords, unit.civ)) {
|
||||
unit.statuses = [];
|
||||
continue;
|
||||
}
|
||||
unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ));
|
||||
unit.statuses = this._isEncircled(unit.coords, unit.civ) ? [STATUS_ENCIRCLED] : [];
|
||||
}
|
||||
for (const city of this.cities) {
|
||||
if (!this._hasAdjacentHostile(city.coords, city.civ)) {
|
||||
city.statuses = [];
|
||||
continue;
|
||||
}
|
||||
city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ));
|
||||
city.statuses = this._isEncircled(city.coords, city.civ) ? [STATUS_BESIEGED] : [];
|
||||
}
|
||||
},
|
||||
|
||||
// The trigger: encirclement is only evaluated when a hostile unit is next to
|
||||
// the tile.
|
||||
// Whether a hostile unit stands next to `coords`. Kept as the cheap trigger
|
||||
// for callers that only care whether a fight is near.
|
||||
_hasAdjacentHostile(coords, civ) {
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return true;
|
||||
@@ -35,20 +28,60 @@ export const statusMethods = {
|
||||
return false;
|
||||
},
|
||||
|
||||
_componentsFor(cache, civ) {
|
||||
if (!cache.has(civ)) cache.set(civ, this._landComponents(civ));
|
||||
return cache.get(civ);
|
||||
// A tile is encircled when no enemy warship can break the ring: it has no
|
||||
// working port, and every land tile beside it is held by an enemy unit. A
|
||||
// tile with no land neighbours has no ring to close, so it is not encircled.
|
||||
_isEncircled(coords, civ) {
|
||||
if (this._hasSeaSupply(coords, civ)) return false;
|
||||
return this._landRingBlocked(coords, civ);
|
||||
},
|
||||
|
||||
_landRingBlocked(coords, civ) {
|
||||
let land = 0;
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (!tile || tile.terrainClass !== "Land") continue;
|
||||
land += 1;
|
||||
if (!this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return false;
|
||||
}
|
||||
return land > 0;
|
||||
},
|
||||
|
||||
// A friendly city's port supplies its own tile by sea, unless the port is
|
||||
// blockaded.
|
||||
_hasSeaSupply(coords, civ) {
|
||||
const city = this.cityAt(coords);
|
||||
if (!city || city.civ !== civ) return false;
|
||||
if (!this.hasCityBuilding(city, "port")) return false;
|
||||
return !this._isPortBlockaded(city);
|
||||
},
|
||||
|
||||
// A port is blockaded when enemy warships hold every sea tile around the city.
|
||||
_isPortBlockaded(city) {
|
||||
let sea = 0;
|
||||
let held = 0;
|
||||
for (const neighbour of this._neighbours(city.coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (!tile || tile.terrainClass !== "Sea") continue;
|
||||
sea += 1;
|
||||
if (this.unitsAt(neighbour).some(
|
||||
(unit) => unit.civ !== city.civ && this._isNaval(unit))) {
|
||||
held += 1;
|
||||
}
|
||||
}
|
||||
return sea > 0 && held === sea;
|
||||
},
|
||||
|
||||
_isNaval(unit) {
|
||||
const proto = this.unitProto(unit);
|
||||
// Aircraft may fly over the sea, but only a hull counts as naval supply.
|
||||
return !!(proto && !proto.air && proto.traversableTerrains.includes("Sea"));
|
||||
return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea"));
|
||||
},
|
||||
|
||||
// Land connected components for one civ, with enemy-occupied and enemy-held
|
||||
// city tiles acting as walls. Each component remembers how many of the civ's
|
||||
// cities it contains, which is all the supply check needs.
|
||||
// cities it contains. Used by the unit supply rule to find a clear route to
|
||||
// an allied city.
|
||||
_landComponents(civ) {
|
||||
const byTile = new Map();
|
||||
const cityCountByComp = new Map();
|
||||
@@ -89,76 +122,6 @@ export const statusMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// A city is besieged when it no longer shares a land component with any
|
||||
// other city of its civ. A lone city is never besieged.
|
||||
_isCityBesieged(city, info) {
|
||||
if (info.totalCities < 2) return false;
|
||||
const component = info.byTile.get(key(city.coords.x, city.coords.y));
|
||||
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
|
||||
return count <= 1;
|
||||
},
|
||||
|
||||
_unitStatuses(unit, info) {
|
||||
const city = this.cityAt(unit.coords);
|
||||
const inOwnCity = city && city.civ === unit.civ;
|
||||
let encircled;
|
||||
if (inOwnCity) {
|
||||
encircled = this._isCityBesieged(city, info);
|
||||
} else {
|
||||
const component = info.byTile.get(key(unit.coords.x, unit.coords.y));
|
||||
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
|
||||
encircled = count === 0;
|
||||
}
|
||||
const excludeCity = inOwnCity ? city.id : 0;
|
||||
if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) {
|
||||
encircled = false;
|
||||
}
|
||||
return encircled ? [STATUS_ENCIRCLED] : [];
|
||||
},
|
||||
|
||||
_cityStatuses(city, info) {
|
||||
const besieged = this._isCityBesieged(city, info) &&
|
||||
!this._hasMaritimeRoute(city.coords, city.civ, city.id);
|
||||
return besieged ? [STATUS_BESIEGED] : [];
|
||||
},
|
||||
|
||||
// The maritime exception: with an allied naval unit on an adjacent sea tile,
|
||||
// a lane is traced across water to a different coastal allied city. Enemy
|
||||
// units on the water block it.
|
||||
_hasMaritimeRoute(origin, civ, excludeCityId) {
|
||||
const visited = new Set();
|
||||
let frontier = [];
|
||||
for (const neighbour of this._neighbours(origin)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (!tile || tile.terrainClass !== "Sea") continue;
|
||||
if (!this.unitsAt(neighbour).some((unit) => unit.civ === civ && this._isNaval(unit))) continue;
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (visited.has(nk)) continue;
|
||||
visited.add(nk);
|
||||
frontier.push(neighbour);
|
||||
}
|
||||
while (frontier.length > 0) {
|
||||
const next = [];
|
||||
for (const coords of frontier) {
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const city = this.cityAt(neighbour);
|
||||
if (city && city.civ === civ && city.id !== excludeCityId) return true;
|
||||
}
|
||||
for (const neighbour of this._neighbours(coords)) {
|
||||
const tile = this.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (!tile || tile.terrainClass !== "Sea") continue;
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (visited.has(nk)) continue;
|
||||
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) continue;
|
||||
visited.add(nk);
|
||||
next.push(neighbour);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_statusHpLossPerDay(def, inCity) {
|
||||
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
|
||||
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
|
||||
|
||||
@@ -7,12 +7,13 @@ import { ECONOMY } from "../data/economy.js";
|
||||
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
|
||||
import { TRENCH } from "../data/combat.js";
|
||||
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
||||
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
|
||||
|
||||
export const warfareMethods = {
|
||||
// ----------------------------------------------------------- trenches --
|
||||
|
||||
// Infantry dig in on a land tile their nation controls. A trench shelters
|
||||
// the garrison from melee and artillery at the cost of local GDP.
|
||||
// the garrison from melee and artillery at the cost of local production.
|
||||
requestDigTrench(unitIds) {
|
||||
const units = this._unitsForOrder(unitIds);
|
||||
if (units.length === 0) return false;
|
||||
@@ -54,9 +55,17 @@ export const warfareMethods = {
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
if (this.tileImprovements.has(k)) return false;
|
||||
if (proto.coastal && !this._isCoastalTile(coords)) return false;
|
||||
// Resource producers feed the map, not a city: they stand on open land.
|
||||
if (proto.resource && this.cityAt(coords)) return false;
|
||||
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
||||
const gdp = this.getPlayerGdp(civ);
|
||||
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
|
||||
if (!this._spendBudget(civ, proto.buildCost)) return false;
|
||||
if (!this._spendBudget(civ, proto.buildCost, "tilebuild")) return false;
|
||||
const materials = tileImprovementResourceCost(proto, proto.buildCost);
|
||||
if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) {
|
||||
this._refundBudget(civ, proto.buildCost, "tilebuild");
|
||||
return false;
|
||||
}
|
||||
const work = {
|
||||
coords: { x: coords.x, y: coords.y },
|
||||
id,
|
||||
@@ -189,6 +198,8 @@ export const warfareMethods = {
|
||||
return true;
|
||||
}
|
||||
this.tileImprovementHp.set(k, hp);
|
||||
// The damage leaves a steel debt: the work heals only once it is paid off.
|
||||
this._recordRepairDebt(coords, amount);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -207,13 +218,14 @@ export const warfareMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// The hourly maintenance of every military improvement a nation holds.
|
||||
// The daily steel every military and resource improvement a nation holds
|
||||
// wears out. Fuel and operating energy are charged through the grid instead.
|
||||
getTileImprovementUpkeep(civ) {
|
||||
let total = 0;
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (proto) total += proto.maintenance;
|
||||
if (proto) total += protoUpkeep(proto, proto.buildCost || 0).steel;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
@@ -224,12 +236,23 @@ export const warfareMethods = {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!proto) continue;
|
||||
const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
|
||||
entry.amount += proto.maintenance;
|
||||
const resources = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const amount = this._upkeepCost(resources);
|
||||
if (amount <= 0) continue;
|
||||
const entry = byType.get(id) || {
|
||||
label: proto.name,
|
||||
amount: 0,
|
||||
resources: { steel: 0, energy: 0, hightech: 0 },
|
||||
count: 0,
|
||||
};
|
||||
entry.amount += amount;
|
||||
entry.resources.steel += resources.steel;
|
||||
entry.resources.energy += resources.energy;
|
||||
entry.resources.hightech += resources.hightech;
|
||||
entry.count += 1;
|
||||
byType.set(id, entry);
|
||||
}
|
||||
return Array.from(byType.values()).filter((entry) => entry.amount > 0);
|
||||
return Array.from(byType.values());
|
||||
},
|
||||
|
||||
// ------------------------------------------------------ transports --
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// terrain predicates the rest of the model leans on.
|
||||
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.js";
|
||||
import { ROADS } from "../data/roads.js";
|
||||
import { transportImprovement } from "../data/improvements.js";
|
||||
import { buildRoadNetwork } from "../roads.js";
|
||||
@@ -82,6 +83,7 @@ export const worldMethods = {
|
||||
: this._placeCities(capitals);
|
||||
for (const site of sites) this._spawnCity(site);
|
||||
this._rebuildCityIndex();
|
||||
this._seedCityPorts();
|
||||
this._generateRoads(cached ? cached.roads : null);
|
||||
if (!cached) {
|
||||
cachePut(settlementCache, cacheKey, {
|
||||
@@ -98,6 +100,18 @@ export const worldMethods = {
|
||||
this._initPolitics();
|
||||
this._generateEthnicMakeup();
|
||||
this._balanceStartingEconomy();
|
||||
// Every nation opens with the resource works its economy needs, laid out
|
||||
// before the budgets are founded so the opening treasury already reflects
|
||||
// them.
|
||||
this._seedResourceBuildings();
|
||||
// The terrain and where the starter industry landed give each region a
|
||||
// different income per head. Unworked land is not what makes a region rich
|
||||
// (food comes from the land, not the headcount), so people now move out of
|
||||
// the poor regions until every region of a nation is level.
|
||||
this._equalizeRegionIncomes();
|
||||
// The starter industry lifts raw GDP unevenly; restore the equal starting
|
||||
// production per head without disturbing the grid demand the seeding planned.
|
||||
this._rebalanceProduction();
|
||||
// Budgets are founded once the balanced economy is known, so every nation
|
||||
// opens on 1 January 2000 with a tenth of its own starting GDP.
|
||||
this._initBudgets();
|
||||
@@ -151,6 +165,17 @@ export const worldMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Every naval city opens the game with a port. It is the only way a nation can
|
||||
// reach resources on other continents, so a coastal city is never landlocked
|
||||
// in trade from the first turn.
|
||||
_seedCityPorts() {
|
||||
const index = this.protoBuildings.findIndex((proto) => proto.id === "port");
|
||||
if (index < 0) return;
|
||||
for (const city of this.cities) {
|
||||
if (this.isCoastalCity(city)) city.buildings[index] = 1;
|
||||
}
|
||||
},
|
||||
|
||||
// Picks the civilisation's next city name, skipping any a city elsewhere has
|
||||
// already taken, and falls back to a numbered name so the whole world never
|
||||
// has two cities called the same thing.
|
||||
@@ -221,6 +246,9 @@ export const worldMethods = {
|
||||
// A transport's cargo and any boarding under way.
|
||||
cargo: [],
|
||||
embarking: null,
|
||||
// A land unit's carried food, in days. Air and naval units use their own
|
||||
// endurance models instead.
|
||||
foodDays: RESOURCE_RULES.unitSupplyDays,
|
||||
};
|
||||
if (proto.air) {
|
||||
unit.homeCityId = homeCityId;
|
||||
@@ -257,13 +285,13 @@ export const worldMethods = {
|
||||
// Map generation should not hand one civilisation a richer start than
|
||||
// another. Terrain makes the raw territories unequal, so after the world is
|
||||
// populated we scale each civ's population to the common mean and then its
|
||||
// GDP per capita to the mean of the raw per-capita figures. Total population
|
||||
// production per head to the mean of the raw figures. Total population
|
||||
// and total GDP are preserved; from here on the factors are constants and
|
||||
// terrain, buildings, technology and war move the numbers normally.
|
||||
_balanceStartingEconomy() {
|
||||
const count = this.civilisations.length;
|
||||
this._populationBalance = new Map();
|
||||
this._perCapitaBalance = new Map();
|
||||
this._productionBalance = new Map();
|
||||
if (count <= 1) return;
|
||||
const bases = [];
|
||||
let totalPopulation = 0;
|
||||
@@ -276,16 +304,36 @@ export const worldMethods = {
|
||||
}
|
||||
if (totalPopulation <= 0) return;
|
||||
const targetPopulation = totalPopulation / count;
|
||||
const targetPerCapita = totalGdp / totalPopulation;
|
||||
const targetPerHead = totalGdp / totalPopulation;
|
||||
// Kept so the production balance can be restored once the starter industry
|
||||
// has been laid out and the raw figures have moved.
|
||||
this._targetProductionPerHead = targetPerHead;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const base = bases[i];
|
||||
if (base.population <= 0) continue;
|
||||
const perCapita = base.gdp / base.population;
|
||||
const perHead = base.gdp / base.population;
|
||||
this._populationBalance.set(i, targetPopulation / base.population);
|
||||
this._perCapitaBalance.set(i, perCapita > 0 ? targetPerCapita / perCapita : 1);
|
||||
this._productionBalance.set(i, perHead > 0 ? targetPerHead / perHead : 1);
|
||||
}
|
||||
},
|
||||
|
||||
// Seeding the starter resource works changes every nation's raw GDP, but the
|
||||
// production balance must still hold: nations should not open richer just
|
||||
// because their land happens to host more industry. Re-derive the production
|
||||
// factor from the post-seeding figures against the same target, which also
|
||||
// leaves each nation's balanced GDP (and so its grid demand) exactly where
|
||||
// the seeding planned it.
|
||||
_rebalanceProduction() {
|
||||
if (!(this._targetProductionPerHead > 0)) return;
|
||||
for (let i = 0; i < this.civilisations.length; i++) {
|
||||
const base = this._basePlayerAggregates(i);
|
||||
if (base.population <= 0) continue;
|
||||
const perHead = base.gdp / base.population;
|
||||
this._productionBalance.set(i, perHead > 0 ? this._targetProductionPerHead / perHead : 1);
|
||||
}
|
||||
this._gdpPerCapitaCache.clear();
|
||||
},
|
||||
|
||||
_isLand(coords) {
|
||||
const tile = this.tiles[key(coords.x, coords.y)];
|
||||
return !!tile && tile.terrainClass === "Land";
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Pure resource maths shared by the server (which simulates the economy) and
|
||||
// the browser (which explains it). Everything here is framework-free and takes
|
||||
// plain numbers, so both sides derive identical figures from the same rules in
|
||||
// shared/data/resources.js.
|
||||
|
||||
import {
|
||||
RESOURCE_IDS,
|
||||
RESOURCE_RULES,
|
||||
RESOURCE_MARKET,
|
||||
RESOURCE_MARKET_BASE,
|
||||
resourceById,
|
||||
} from "./data/resources.js";
|
||||
import { EFFECT_RESEARCH } from "./data/effects.js";
|
||||
|
||||
const DAYS_PER_YEAR = 365;
|
||||
|
||||
// --------------------------------------------------------------- formatting --
|
||||
|
||||
// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
|
||||
export function formatEnergy(kwh) {
|
||||
const value = Math.max(0, kwh);
|
||||
if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
|
||||
if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
|
||||
if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
|
||||
return `${trim(value)} kWh`;
|
||||
}
|
||||
|
||||
// A material in its own friendly unit: tonnes step up through kt and Mt,
|
||||
// carats through kct, high-tech through ku.
|
||||
export function formatResourceAmount(id, value) {
|
||||
if (id === "energy") return formatEnergy(value);
|
||||
const amount = Math.max(0, value);
|
||||
let unit = resourceById(id) ? resourceById(id).unit : "";
|
||||
let scaled = amount;
|
||||
let prefix = "";
|
||||
if (amount >= 1e9) {
|
||||
scaled = amount / 1e9;
|
||||
prefix = "M";
|
||||
} else if (amount >= 1e6) {
|
||||
scaled = amount / 1e6;
|
||||
prefix = "k";
|
||||
}
|
||||
return `${trim(scaled)} ${prefix}${unit}`;
|
||||
}
|
||||
|
||||
function trim(value) {
|
||||
if (value >= 100) return String(Math.round(value));
|
||||
if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- per-person --
|
||||
|
||||
export function foodNeedPerDay(population) {
|
||||
return Math.max(0, population) * RESOURCE_RULES.foodPerPersonPerDay;
|
||||
}
|
||||
|
||||
export function steelNeedPerDay(population) {
|
||||
return Math.max(0, population) * RESOURCE_RULES.steelPerPersonPerDay;
|
||||
}
|
||||
|
||||
export function luxuryNeedPerDay(population) {
|
||||
return Math.max(0, population) * RESOURCE_RULES.luxuryPerPersonPerDay;
|
||||
}
|
||||
|
||||
// What one worked land tile grows in a day. Food comes from the land, not from
|
||||
// how many people live on it: a farm feeds whoever works it, so moving people
|
||||
// between regions changes only the income per head, never the harvest. A tile
|
||||
// feeds roughly `ECONOMY.basePopulation` people at factor 1.
|
||||
export function tileFoodOutput(tile) {
|
||||
const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
|
||||
return RESOURCE_RULES.foodPerTilePerDay * factor;
|
||||
}
|
||||
|
||||
// How much food a city wants in store before the coming month: a month at the
|
||||
// current appetite, with a 130% cushion.
|
||||
export function foodMonthlyTarget(population) {
|
||||
return foodNeedPerDay(population) *
|
||||
RESOURCE_RULES.daysPerMonth *
|
||||
RESOURCE_RULES.foodMonthlyBuffer;
|
||||
}
|
||||
|
||||
// A city's baseline electric appetite, kWh a day, from its yearly GDP.
|
||||
export function cityEnergyPerDay(gdp) {
|
||||
return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
|
||||
}
|
||||
|
||||
// What one building level, unit or tile work wears out in a day, from its money
|
||||
// value. Advanced things also burn high-tech. Buildings pass their level's cost
|
||||
// (so upkeep grows with the level), units their training cost.
|
||||
export function upkeepResources(value, advanced = false) {
|
||||
const amount = Math.max(0, value);
|
||||
return {
|
||||
steel: amount * RESOURCE_RULES.upkeepSteelPerEuro,
|
||||
energy: amount * RESOURCE_RULES.upkeepEnergyPerEuro,
|
||||
hightech: advanced ? amount * RESOURCE_RULES.upkeepHighTechPerEuro : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The daily upkeep of a catalogue entry. An explicit `materialUpkeep` override
|
||||
// wins -- guns and aircraft are cheap parked and costly when fired, so their
|
||||
// standing bill is set by hand -- otherwise it scales with the entry's value.
|
||||
export function protoUpkeep(proto, value) {
|
||||
if (proto && proto.materialUpkeep) {
|
||||
return {
|
||||
steel: proto.materialUpkeep.steel || 0,
|
||||
energy: proto.materialUpkeep.energy || 0,
|
||||
hightech: proto.materialUpkeep.hightech || 0,
|
||||
};
|
||||
}
|
||||
return upkeepResources(value, !!(proto && proto.advanced));
|
||||
}
|
||||
|
||||
// The materials and power a tile improvement spends to be built: steel in
|
||||
// proportion to its money cost, the electricity that steel embodies, and
|
||||
// high-tech for an advanced work. Shared so the server's bill and the browser's
|
||||
// cost buttons agree on the same figures.
|
||||
export function tileImprovementResourceCost(proto, buildCost = null) {
|
||||
const cost = Math.max(0, buildCost !== null ? buildCost : ((proto && proto.buildCost) || 0));
|
||||
return {
|
||||
steel: cost * RESOURCE_RULES.steelPerBudgetEuro,
|
||||
energy: cost * RESOURCE_RULES.constructionEnergyPerBudgetEuro,
|
||||
hightech: proto && proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// The steel and high-tech a building or unit spends to be built: steel in
|
||||
// proportion to its money cost, high-tech for an advanced item (more with every
|
||||
// level). Mirrors the server's construction bill so the browser can quote it at
|
||||
// today's prices.
|
||||
export function constructionResourceCost(proto, level = 0, buildCost = null) {
|
||||
const cost = buildCost !== null ? buildCost : ((proto && (proto.buildCost || proto.baseCost)) || 0);
|
||||
const advanced = !!(proto && (proto.advanced ||
|
||||
(proto.effects || []).some((e) => e.stat === EFFECT_RESEARCH)));
|
||||
return {
|
||||
steel: Math.max(0, cost) * RESOURCE_RULES.steelPerBudgetEuro,
|
||||
hightech: advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Today's global price index: the average ratio of each resource's current
|
||||
// price to its base. One figure lets a money cost with no material bill of its
|
||||
// own be quoted at the market.
|
||||
export function marketPriceIndex(prices) {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const base = RESOURCE_MARKET_BASE[id];
|
||||
if (!(base > 0)) continue;
|
||||
const price = prices && prices[id] !== undefined ? prices[id] : base;
|
||||
total += price / base;
|
||||
count += 1;
|
||||
}
|
||||
return count > 0 ? total / count : 1;
|
||||
}
|
||||
|
||||
// The market value of a resource bill at the given prices, a resource at a time.
|
||||
export function resourceMarketValue(resources, prices) {
|
||||
let total = 0;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const amount = (resources && resources[id]) || 0;
|
||||
if (!(amount > 0)) continue;
|
||||
const price = prices && prices[id] !== undefined ? prices[id] : RESOURCE_MARKET_BASE[id];
|
||||
total += amount * (price || 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// The energy a material producer burns to make its output: the energy-per-unit
|
||||
// ratio from the rules, whichever resource it makes.
|
||||
export function producerEnergyPerDay(id, outputPerDay) {
|
||||
if (id === "steel") return outputPerDay * RESOURCE_RULES.energyPerSteelTonne;
|
||||
if (id === "food") return outputPerDay * RESOURCE_RULES.energyPerFoodTonne;
|
||||
if (id === "hightech") return outputPerDay * RESOURCE_RULES.energyPerHighTechUnit;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delivery --
|
||||
|
||||
// The kWh of transport energy one material tonne costs over `tiles` tiles in the
|
||||
// given mode. A material tonne and an energy tonne (10 GWh) share the formula.
|
||||
export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
|
||||
const d = RESOURCE_RULES.deliveryEnergy;
|
||||
const perTile = mode === "train"
|
||||
? d.train
|
||||
: mode === "ship"
|
||||
? d.ship
|
||||
: mode === "road"
|
||||
? d.truckRoad
|
||||
: d.truckOffRoad * Math.max(tileMovementCost, 0);
|
||||
return tonnes * Math.max(tiles, 0) * perTile;
|
||||
}
|
||||
|
||||
// The energy cost of delivering `amount` of a resource over `tiles`. Every
|
||||
// resource carries a mass per canonical unit, so a carat or a chip is charged
|
||||
// for the tonnes it actually weighs rather than as if it were a tonne.
|
||||
export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
|
||||
const proto = resourceById(id);
|
||||
const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
|
||||
return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
|
||||
}
|
||||
|
||||
// The transport mode a tile offers, most efficient first: rail beats road, and
|
||||
// an unimproved tile falls back to an off-road truck. Sea legs ship.
|
||||
export function transportModeForTile(tile, road, railway, sea) {
|
||||
if (sea || (tile && tile.terrainClass === "Sea")) return "ship";
|
||||
if (railway) return "train";
|
||||
if (road) return "road";
|
||||
return "truck";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- market --
|
||||
|
||||
// Moves a price toward what the day's world-wide supply and demand imply. A
|
||||
// glut pushes it down, a shortage up, and the clamp keeps the market sane. A
|
||||
// zero-supply day is treated as the sharpest shortage.
|
||||
export function nextMarketPrice(basePrice, price, supply, demand) {
|
||||
if (!(demand > 0)) return basePrice;
|
||||
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxMultiplier;
|
||||
const pressure = (ratio - 1) * RESOURCE_MARKET.adjustment;
|
||||
const moved = price * (1 + pressure);
|
||||
const min = basePrice * RESOURCE_MARKET.minMultiplier;
|
||||
const max = basePrice * RESOURCE_MARKET.maxMultiplier;
|
||||
return Math.max(min, Math.min(max, moved));
|
||||
}
|
||||
@@ -107,13 +107,13 @@ export function unitDefense(baseDefense, tile, hasCity) {
|
||||
return baseDefense * defenseMultiplier(tile, hasCity);
|
||||
}
|
||||
|
||||
// Every GDP-per-capita modifier of a tile, in display order. `tile` supplies the
|
||||
// Every production modifier of a tile, in display order. `tile` supplies the
|
||||
// terrain multiplier and its name; `cityKind` is "capital", "city" or null and
|
||||
// `nextToCity` is true when the tile borders one of its owner's cities. The
|
||||
// pillage penalty and battle deficit are the lasting marks of war. Shared by the
|
||||
// server (which derives each tile's GDP) and the browser (which explains it), so
|
||||
// the numbers always agree.
|
||||
export function gdpPerCapitaFactors(tile, {
|
||||
// server (which derives each tile's production) and the browser (which explains
|
||||
// it), so the numbers always agree.
|
||||
export function productionFactors(tile, {
|
||||
cityKind = null,
|
||||
nextToCity = false,
|
||||
pillagePenalty = 1,
|
||||
@@ -122,28 +122,28 @@ export function gdpPerCapitaFactors(tile, {
|
||||
regionalMultiplier = 1,
|
||||
regionalLabel = null,
|
||||
nationalMultiplier = 1,
|
||||
portGdp = 0,
|
||||
portProduction = 0,
|
||||
naturalGrowth = 0,
|
||||
} = {}) {
|
||||
const factors = [];
|
||||
const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0;
|
||||
const terrainMultiplier = tile ? tile.productionMultiplier || 0 : 0;
|
||||
factors.push({ label: tile ? tile.terrainType : "Unknown", multiplier: terrainMultiplier });
|
||||
if (cityKind === "capital") {
|
||||
factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileGdpMultiplier });
|
||||
factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileProductionMultiplier });
|
||||
} else if (cityKind === "city") {
|
||||
factors.push({ label: "City", multiplier: ECONOMY.cityTileGdpMultiplier });
|
||||
factors.push({ label: "City", multiplier: ECONOMY.cityTileProductionMultiplier });
|
||||
} else if (nextToCity) {
|
||||
factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingGdpBonus });
|
||||
factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingProductionBonus });
|
||||
}
|
||||
if (pillagePenalty !== 1) {
|
||||
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
|
||||
}
|
||||
if (trench) {
|
||||
factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
|
||||
factors.push({ label: "Trenches", multiplier: TRENCH.productionMultiplier });
|
||||
}
|
||||
// City improvements only shape the region their city controls, so a tile's
|
||||
// GDP per capita carries its region's multiplier plus the nation-wide one
|
||||
// from government and research. Both are factors on the per-capita figure,
|
||||
// production carries its region's multiplier plus the nation-wide one
|
||||
// from government and research. Both are factors on the production figure,
|
||||
// never an adjustment to the final GDP. They are applied before the absolute
|
||||
// war damage below, so a scar stays proportional to the tile's productivity.
|
||||
if (regionalMultiplier !== 1) {
|
||||
@@ -155,13 +155,14 @@ export function gdpPerCapitaFactors(tile, {
|
||||
if (nationalMultiplier !== 1) {
|
||||
factors.push({ label: "National modifiers", multiplier: nationalMultiplier });
|
||||
}
|
||||
// A warm-water port adds a flat per-capita figure to its whole region, scaled
|
||||
// by the sea tiles beside the city, before war damage is taken off.
|
||||
if (portGdp > 0) {
|
||||
factors.push({ label: "Port", amount: portGdp });
|
||||
// A warm-water port adds a flat production-per-head figure to its whole
|
||||
// region, scaled by the sea tiles beside the city, before war damage is taken
|
||||
// off.
|
||||
if (portProduction > 0) {
|
||||
factors.push({ label: "Port", amount: portProduction });
|
||||
}
|
||||
// Natural growth is a flat per-capita gain added on top of the tile's
|
||||
// productivity, growing by a constant amount each day.
|
||||
// Natural growth is a flat production-per-head gain added on top of the
|
||||
// tile's productivity, growing by a constant amount each day.
|
||||
if (naturalGrowth > 0) {
|
||||
factors.push({ label: "Natural growth", amount: naturalGrowth });
|
||||
}
|
||||
@@ -171,9 +172,9 @@ export function gdpPerCapitaFactors(tile, {
|
||||
return factors;
|
||||
}
|
||||
|
||||
// Applies the `gdpPerCapitaFactors` list to `base`, in order, never falling
|
||||
// Applies the `productionFactors` list to `base`, in order, never falling
|
||||
// below zero.
|
||||
export function gdpPerCapitaFromFactors(base, factors) {
|
||||
export function productionFromFactors(base, factors) {
|
||||
let total = base;
|
||||
for (const factor of factors) {
|
||||
if (factor.amount !== undefined) total += factor.amount;
|
||||
@@ -181,3 +182,14 @@ export function gdpPerCapitaFromFactors(base, factors) {
|
||||
}
|
||||
return total > 0 ? total : 0;
|
||||
}
|
||||
|
||||
// The multiplier product of a `productionFactors` list, ignoring the absolute
|
||||
// per-head terms (a port's flat production, natural growth, battle damage).
|
||||
// This is what scales a tile's physical output.
|
||||
export function productionMultiplier(factors) {
|
||||
let total = 1;
|
||||
for (const factor of factors) {
|
||||
if (factor.multiplier !== undefined) total *= factor.multiplier;
|
||||
}
|
||||
return total > 0 ? total : 0;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export class TerrainStats {
|
||||
return this._value(coords, "populationMultiplier");
|
||||
}
|
||||
|
||||
gdpMultiplier(coords) {
|
||||
return this._value(coords, "gdpMultiplier");
|
||||
productionMultiplier(coords) {
|
||||
return this._value(coords, "productionMultiplier");
|
||||
}
|
||||
|
||||
movementCostMultiplier(coords) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { parseKey, key } from "../shared/hex.js";
|
||||
import { AIR_STRIKE } from "../shared/data.js";
|
||||
import { AIR_STRIKE, RESOURCE_RULES } from "../shared/data.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
@@ -97,8 +97,8 @@ export class AirTest extends TestCase {
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const shipyard = buildingIndex(state, "shipyard");
|
||||
const city = coastalCity(state, 0);
|
||||
this.assertFalse(state.requestBuild(city.id, shipyard), "no port, no shipyard");
|
||||
giveBuilding(state, city, "port");
|
||||
// Every naval city opens the game with a port.
|
||||
this.assertTrue(state.hasCityBuilding(city, "port"), "a naval city starts with a port");
|
||||
this.assertTrue(state.requestBuild(city.id, shipyard), "a port unlocks the shipyard");
|
||||
|
||||
// Even with a port, an inland city has no water to launch from.
|
||||
@@ -160,13 +160,18 @@ export class AirTest extends TestCase {
|
||||
this.assertNotNull(target, "there is a target tile within range");
|
||||
const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry"));
|
||||
|
||||
const totalTech = () => state.cities.filter((c) => c.civ === 0)
|
||||
.reduce((sum, c) => sum + (state.getCityResourceStock(c).hightech || 0), 0);
|
||||
const beforeTech = totalTech();
|
||||
|
||||
this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the strike is accepted");
|
||||
this.assertEqual(unit.coords.x, city.coords.x, "it has not taken off yet");
|
||||
// A sortie is paid for in munitions, not money.
|
||||
this.assertApprox(
|
||||
state.getBudget(0),
|
||||
BIG_BUDGET - AIR_STRIKE.cost,
|
||||
beforeTech - totalTech(),
|
||||
RESOURCE_RULES.combatConsumption.airStrike.hightech,
|
||||
1e-6,
|
||||
"the strike was paid for up front"
|
||||
"the sortie spent its high-tech up front"
|
||||
);
|
||||
|
||||
state.advanceHour();
|
||||
|
||||
@@ -35,18 +35,20 @@ export class AppTest extends TestCase {
|
||||
joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
|
||||
this.assertFalse(env.$("#screen-game").hasClass("active"), "no game screen before picking");
|
||||
|
||||
const state = smallState();
|
||||
const britain = state.civilisations.findIndex((civ) => civ.id === "britain");
|
||||
joinSocket.simulateMessage({
|
||||
t: "choose_civ",
|
||||
civs: [{ index: 1, id: "britain", name: "Britain" }],
|
||||
civs: [{ index: britain, id: "britain", name: "Britain" }],
|
||||
});
|
||||
this.assertTrue(env.$("#screen-choose").hasClass("active"));
|
||||
this.assertSize(env.$("#choose-civs li"), 1);
|
||||
env.$("#choose-civs li").first().click();
|
||||
this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: 1 });
|
||||
this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: britain });
|
||||
|
||||
joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
|
||||
joinSocket.simulateMessage({ t: "civ_assigned", civ: britain });
|
||||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||||
joinSocket.simulateMessage({ t: "game_state", state: smallState().snapshot(1) });
|
||||
joinSocket.simulateMessage({ t: "game_state", state: state.snapshot(britain) });
|
||||
this.assertEqual(env.$("#civ-name").text(), "Britain");
|
||||
this.assertFalse(env.$("#stat-pop").text() === "0");
|
||||
} finally {
|
||||
@@ -114,19 +116,18 @@ export class AppTest extends TestCase {
|
||||
this.assertEqual(socket.lastSent(), { t: "login", name: "adrien", password: "admin" });
|
||||
|
||||
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "adrien" });
|
||||
const state = smallState();
|
||||
const france = state.civilisations.findIndex((civ) => civ.id === "france");
|
||||
socket.simulateMessage({
|
||||
t: "choose_civ",
|
||||
civs: [
|
||||
{ index: 4, id: "france", name: "France" },
|
||||
{ index: 1, id: "britain", name: "Britain" },
|
||||
],
|
||||
civs: [{ index: france, id: "france", name: "France" }],
|
||||
});
|
||||
this.assertEqual(socket.lastSent(), { t: "claim_civ", civ: 4 }, "France is claimed");
|
||||
this.assertEqual(socket.lastSent(), { t: "claim_civ", civ: france }, "France is claimed");
|
||||
this.assertFalse(env.$("#screen-choose").hasClass("active"), "the picker is skipped");
|
||||
|
||||
socket.simulateMessage({ t: "civ_assigned", civ: 4 });
|
||||
socket.simulateMessage({ t: "civ_assigned", civ: france });
|
||||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||||
socket.simulateMessage({ t: "game_state", state: smallState().snapshot(0) });
|
||||
socket.simulateMessage({ t: "game_state", state: state.snapshot(france) });
|
||||
this.assertEqual(env.$("#civ-name").text(), "France");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { BUDGET_HISTORY_MONTHS } from "../shared/game_state/budget.js";
|
||||
|
||||
export class BudgetTest extends TestCase {
|
||||
test_the_opening_month_is_the_only_report() {
|
||||
const state = smallState();
|
||||
const months = state.budgetMonthReports(0);
|
||||
this.assertSize(months, 1, "only the live month exists at the start");
|
||||
this.assertTrue(months[0].isCurrent, "the live month is flagged current");
|
||||
this.assertEqual(months[0].label, "January 2000");
|
||||
this.assertEqual(months[0].expenses.length, 0, "nothing has been spent yet");
|
||||
this.assertEqual(months[0].net, 0, "the month opens balanced");
|
||||
}
|
||||
|
||||
test_spending_is_filed_under_its_category() {
|
||||
const state = smallState();
|
||||
state._spendBudget(0, 1_000, "training");
|
||||
state._spendBudget(0, 4_000, "construction");
|
||||
state._recordBudgetCash(0, "income", 9_000);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
|
||||
this.assertApprox(byId.get("training").cash, 1_000);
|
||||
this.assertApprox(byId.get("construction").cash, 4_000);
|
||||
this.assertApprox(report.income, 9_000);
|
||||
this.assertApprox(report.totalCash, 5_000);
|
||||
this.assertApprox(report.net, 4_000);
|
||||
}
|
||||
|
||||
test_a_spend_on_an_empty_treasury_becomes_debt_and_is_recorded() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
// The government always pays; the overdraft is the central bank's loan.
|
||||
this.assertTrue(state._spendBudget(0, 500, "training"));
|
||||
this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const training = report.expenses.find((entry) => entry.id === "training");
|
||||
this.assertApprox(training.cash, 500);
|
||||
}
|
||||
|
||||
test_a_refund_cancels_the_category() {
|
||||
const state = smallState();
|
||||
state._spendBudget(0, 1_000, "construction");
|
||||
state._refundBudget(0, 1_000, "construction");
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
this.assertEqual(report.expenses.length, 0, "a refunded order does not linger");
|
||||
this.assertEqual(report.net, 0);
|
||||
}
|
||||
|
||||
test_daily_upkeep_records_cash_and_market_value() {
|
||||
const state = smallState();
|
||||
state.advanceHour();
|
||||
for (let i = 0; i < 23; i++) state.advanceHour();
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
|
||||
for (const id of ["buildings", "units", "works", "transport"]) {
|
||||
const entry = byId.get(id);
|
||||
this.assertNotNull(entry, `${id} upkeep is recorded`);
|
||||
this.assertTrue(entry.economic >= entry.cash - 1e-6, `${id} market value covers its cash`);
|
||||
}
|
||||
}
|
||||
|
||||
test_rolling_a_month_freezes_a_digest_and_resets() {
|
||||
const state = smallState();
|
||||
state._spendBudget(0, 2_000, "training");
|
||||
// January 2000 has 31 days.
|
||||
for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
|
||||
const months = state.budgetMonthReports(0);
|
||||
this.assertSize(months, 2, "January is frozen behind the live February");
|
||||
this.assertEqual(months[0].label, "January 2000");
|
||||
this.assertTrue(!months[0].isCurrent, "the frozen month is not current");
|
||||
this.assertEqual(months[1].label, "February 2000");
|
||||
this.assertTrue(months[1].isCurrent, "the live month is February");
|
||||
const training = months[0].expenses.find((entry) => entry.id === "training");
|
||||
this.assertApprox(training.cash, 2_000, 1e-6);
|
||||
this.assertEqual(
|
||||
months[1].expenses.find((entry) => entry.id === "training"),
|
||||
undefined,
|
||||
"the new month starts empty"
|
||||
);
|
||||
}
|
||||
|
||||
test_the_history_is_capped() {
|
||||
const state = smallState();
|
||||
for (let i = 0; i < BUDGET_HISTORY_MONTHS + 5; i++) state._rolloverBudget(24000 + i);
|
||||
this.assertSize(state.budgetHistory, BUDGET_HISTORY_MONTHS, "only two years are kept");
|
||||
}
|
||||
|
||||
test_resource_trade_is_mirrored_into_the_month() {
|
||||
const state = smallState();
|
||||
state._recordResourceSpend(0, "goods", 5_000, "steel");
|
||||
state._recordResourceEarn(0, "steel", 2_000);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const trade = report.resourceTrade.find((entry) => entry.id === "steel");
|
||||
this.assertNotNull(trade);
|
||||
this.assertApprox(trade.bought, 5_000);
|
||||
this.assertApprox(trade.sold, 2_000);
|
||||
}
|
||||
|
||||
test_expense_rows_carry_their_resource_detail() {
|
||||
const state = smallState();
|
||||
state._recordBudgetCash(0, "buildings", -500);
|
||||
state._recordBudgetCategoryResource(0, "buildings", "steel", 300);
|
||||
state._recordBudgetCategoryResource(0, "buildings", "hightech", 200);
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const buildings = report.expenses.find((entry) => entry.id === "buildings");
|
||||
this.assertApprox(buildings.cash, 500);
|
||||
this.assertEqual(buildings.buys.length, 2, "both resources are listed");
|
||||
const steel = buildings.buys.find((entry) => entry[0] === "steel");
|
||||
this.assertApprox(steel[1], 300);
|
||||
}
|
||||
|
||||
test_upkeep_records_the_resources_it_bought() {
|
||||
const state = smallState();
|
||||
// Empty the stores so the day's upkeep has to be bought on the market.
|
||||
for (const city of state.cities) {
|
||||
const store = state.getCityResourceStock(city);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
state.budgets.set(0, 1.0e15);
|
||||
for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour();
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const buildings = report.expenses.find((entry) => entry.id === "buildings");
|
||||
this.assertNotNull(buildings, "building upkeep is recorded");
|
||||
this.assertGreater(buildings.buys.length, 0, "the row names the resource it bought");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_month_reports() {
|
||||
const state = smallState();
|
||||
const snapshot = state.snapshot(0);
|
||||
const months = snapshot.viewerStats.budgetMonths;
|
||||
this.assertGreaterOrEqual(months.length, 1);
|
||||
const current = months[months.length - 1];
|
||||
this.assertTrue(current.isCurrent, "the last shipped report is the live month");
|
||||
this.assertTrue(Array.isArray(current.expenses), "the report carries its expenses");
|
||||
}
|
||||
}
|
||||
@@ -108,21 +108,12 @@ export class CombatTest extends TestCase {
|
||||
test_battle_triples_the_fighting_unit_upkeep() {
|
||||
const state = smallState();
|
||||
const { attacker } = engagement(state);
|
||||
const proto = state.protoUnits[attacker.proto];
|
||||
const idleUpkeep = state.getPlayerUpkeep(0) - proto.upkeep * 2;
|
||||
const idleUnits = state.budgetBreakdown(0).unitUpkeep.total - proto.upkeep * 2;
|
||||
this.assertApprox(
|
||||
state.getPlayerUpkeep(0) - idleUpkeep,
|
||||
proto.upkeep * 2,
|
||||
1e-9,
|
||||
"the battle premium is twice the base upkeep"
|
||||
);
|
||||
this.assertApprox(
|
||||
state.budgetBreakdown(0).unitUpkeep.total - idleUnits,
|
||||
proto.upkeep * 2,
|
||||
1e-9,
|
||||
"the budget breakdown reflects the premium"
|
||||
const battle = state._battleUnitIds();
|
||||
this.assertTrue(battle.has(attacker.id), "the attacker is in battle");
|
||||
const multiplier = state._statusUpkeepMultiplier(
|
||||
state._effectiveStatuses(attacker, true)
|
||||
);
|
||||
this.assertGreater(multiplier, 1, "a unit in battle burns more material upkeep");
|
||||
}
|
||||
|
||||
test_a_blocked_move_with_no_route_is_abandoned() {
|
||||
@@ -434,7 +425,7 @@ export class CombatTest extends TestCase {
|
||||
);
|
||||
this.assertApprox(
|
||||
state.getTileGdpPerCapita(b),
|
||||
perCapita * (1 - BATTLE.gdpPerCapitaLossPerDay),
|
||||
perCapita * (1 - BATTLE.productionLossPerDay),
|
||||
1e-6,
|
||||
"the tile loses GDP per capita"
|
||||
);
|
||||
@@ -446,19 +437,19 @@ export class CombatTest extends TestCase {
|
||||
const k = key(b.x, b.y);
|
||||
const perCapita = state.getTileGdpPerCapita(b);
|
||||
state._tickBattleTiles();
|
||||
const deficit = state.tileBattleGdpDeficit.get(k);
|
||||
this.assertApprox(deficit, perCapita * BATTLE.gdpPerCapitaLossPerDay, 1e-6);
|
||||
const deficit = state.tileBattleProductionDeficit.get(k);
|
||||
this.assertApprox(deficit, perCapita * BATTLE.productionLossPerDay, 1e-6);
|
||||
const start = state.tileBattleRecoveryStart.get(k);
|
||||
this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days");
|
||||
|
||||
state.totalHours = start - 1;
|
||||
state._tickGdpRecovery();
|
||||
this.assertApprox(state.tileBattleGdpDeficit.get(k), deficit, 1e-9, "nothing recovers early");
|
||||
state._tickProductionRecovery();
|
||||
this.assertApprox(state.tileBattleProductionDeficit.get(k), deficit, 1e-9, "nothing recovers early");
|
||||
|
||||
state.totalHours = start;
|
||||
state._tickGdpRecovery();
|
||||
state._tickProductionRecovery();
|
||||
this.assertApprox(
|
||||
state.tileBattleGdpDeficit.get(k),
|
||||
state.tileBattleProductionDeficit.get(k),
|
||||
deficit - BATTLE.recoveryPerDay,
|
||||
1e-9,
|
||||
"recovery begins at $100 a day"
|
||||
@@ -466,9 +457,9 @@ export class CombatTest extends TestCase {
|
||||
|
||||
for (let day = 0; day < Math.ceil(deficit / BATTLE.recoveryPerDay) + 1; day++) {
|
||||
state.totalHours += DAY;
|
||||
state._tickGdpRecovery();
|
||||
state._tickProductionRecovery();
|
||||
}
|
||||
this.assertFalse(state.tileBattleGdpDeficit.has(k), "the wound heals completely");
|
||||
this.assertFalse(state.tileBattleProductionDeficit.has(k), "the wound heals completely");
|
||||
this.assertFalse(state.tileBattleRecoveryStart.has(k), "and the clock is cleared");
|
||||
}
|
||||
|
||||
@@ -477,9 +468,9 @@ export class CombatTest extends TestCase {
|
||||
const { b } = joinBattleOn(state, ownedLandPair(state));
|
||||
const k = key(b.x, b.y);
|
||||
const before = state.getTileGdpPerCapita(b);
|
||||
this.assertFalse(state.tileBattleGdpDeficit.has(k), "the tile is untouched at first");
|
||||
this.assertFalse(state.tileBattleProductionDeficit.has(k), "the tile is untouched at first");
|
||||
for (let hour = 0; hour < DAY; hour++) state.tickHour();
|
||||
this.assertTrue(state.tileBattleGdpDeficit.has(k), "the daily tick records the scar");
|
||||
this.assertTrue(state.tileBattleProductionDeficit.has(k), "the daily tick records the scar");
|
||||
this.assertLess(state.getTileGdpPerCapita(b), before, "and the tile is worth less");
|
||||
this.assertEqual(state.tileBattleRecoveryStart.get(k), DAY + BATTLE.recoveryDelayDays * DAY);
|
||||
}
|
||||
@@ -645,10 +636,12 @@ function ownedLandPair(state) {
|
||||
for (const cell of state.landCells) {
|
||||
if (state.civAt(cell) < 0) continue;
|
||||
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
||||
if (state.tileImprovements.has(key(cell.x, cell.y))) continue;
|
||||
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
|
||||
if (!state._isLand(neighbour)) continue;
|
||||
if (state.civAt(neighbour) < 0) continue;
|
||||
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
|
||||
if (state.tileImprovements.has(key(neighbour.x, neighbour.y))) continue;
|
||||
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PILLAGE,
|
||||
pillageSteps,
|
||||
POLICIES,
|
||||
RESOURCE_BUILDINGS,
|
||||
} from "../shared/data.js";
|
||||
|
||||
export class DataTest extends TestCase {
|
||||
@@ -27,6 +28,13 @@ export class DataTest extends TestCase {
|
||||
this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]);
|
||||
}
|
||||
|
||||
test_resource_building_output_is_scaled_up() {
|
||||
// One work stands for a whole industry: the authored output is multiplied
|
||||
// tenfold so fewer of them stand on the map.
|
||||
const mill = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
|
||||
this.assertApprox(mill.outputPerDay, 20_000, 1e-6);
|
||||
}
|
||||
|
||||
test_unknown_lookups_return_null() {
|
||||
this.assertNull(protoUnitById("nope"));
|
||||
this.assertNull(buildingById("nope"));
|
||||
@@ -57,14 +65,14 @@ export class DataTest extends TestCase {
|
||||
const one = pillageSteps(100000, 1);
|
||||
this.assertApprox(one.steps[0].removed, 10000, 1e-6);
|
||||
this.assertApprox(one.steps[0].heal, 10, 1e-9);
|
||||
this.assertApprox(one.gdpMultiplier, PILLAGE.gdpPenalty, 1e-9);
|
||||
this.assertApprox(one.productionMultiplier, PILLAGE.productionPenalty, 1e-9);
|
||||
|
||||
const two = pillageSteps(100000, 2);
|
||||
this.assertApprox(two.steps[0].removed, 10000, 1e-6);
|
||||
this.assertApprox(two.steps[1].removed, 9000, 1e-6);
|
||||
this.assertApprox(two.steps[1].heal, 9, 1e-9);
|
||||
this.assertApprox(two.steps[1].remaining, 81000, 1e-6);
|
||||
this.assertApprox(two.gdpMultiplier, 0.49, 1e-9);
|
||||
this.assertApprox(two.productionMultiplier, 0.49, 1e-9);
|
||||
}
|
||||
|
||||
test_trainable_units_exist_in_the_catalogue() {
|
||||
@@ -133,7 +141,7 @@ export class DataTest extends TestCase {
|
||||
shelters: "civilian_shelter",
|
||||
aa_building: "air_defense",
|
||||
shipyard: "parallel_building",
|
||||
port: "port_gdp",
|
||||
port: "port_production",
|
||||
airport: "air_immigration",
|
||||
};
|
||||
for (const [id, mechanic] of Object.entries(expected)) {
|
||||
@@ -196,7 +204,7 @@ export class DataTest extends TestCase {
|
||||
this.assertEqual(tundra.terrainClass, "Land", "tundra is land");
|
||||
this.assertEqual(tundra.defenseBonus, 0, "tundra stays flat");
|
||||
this.assertApprox(tundra.populationMultiplier, 0.05, 1e-9);
|
||||
this.assertApprox(tundra.gdpMultiplier, 0.2, 1e-9);
|
||||
this.assertApprox(tundra.productionMultiplier, 0.2, 1e-9);
|
||||
this.assertApprox(tundra.movementCostMultiplier, 2.0, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function mapLayers($) {
|
||||
highlight: $("#layer-highlight"),
|
||||
paths: $("#layer-paths"),
|
||||
targets: $("#layer-targets"),
|
||||
resources: $("#layer-resources"),
|
||||
entities: $("#layer-entities"),
|
||||
labels: $("#layer-labels"),
|
||||
};
|
||||
@@ -109,6 +110,19 @@ export function unitsOf(state, civ) {
|
||||
return state.units.filter((unit) => unit.civ === civ).length;
|
||||
}
|
||||
|
||||
// Removes the port every naval city opens with, so a GDP-sensitive test starts
|
||||
// from the same terrain-only economy it would have had before ports were seeded.
|
||||
export function stripPorts(state, civ = null) {
|
||||
const index = state.protoBuildings.findIndex((building) => building.id === "port");
|
||||
if (index < 0) return;
|
||||
for (const city of state.cities) {
|
||||
if (civ !== null && city.civ !== civ) continue;
|
||||
if (city.buildings[index]) delete city.buildings[index];
|
||||
}
|
||||
state._gdpPerCapitaCache.clear();
|
||||
state._touchModifiers();
|
||||
}
|
||||
|
||||
export function snapshotUnit(state, unitId, viewer = 0) {
|
||||
return (state.snapshot(viewer).units || []).find((u) => u.id === unitId) || null;
|
||||
}
|
||||
@@ -120,7 +134,7 @@ export function gdpBuilding(overrides = {}) {
|
||||
baseCost: 100,
|
||||
costAlpha: 2.0,
|
||||
refundFraction: 0.5,
|
||||
effects: [{ stat: "gdp", baseValue: 0.5, exponent: 1.0 }],
|
||||
effects: [{ stat: "production", baseValue: 0.5, exponent: 1.0 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { GameScreen } from "../client/js/game_screen.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY } from "../shared/data.js";
|
||||
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { hoursInNextMonth } from "../shared/game_clock.js";
|
||||
import { compact } from "../shared/text_format.js";
|
||||
import { tileImprovementResourceCost } from "../shared/resources.js";
|
||||
import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
|
||||
import { parseKey } from "../shared/hex.js";
|
||||
import { smallState, adjacentLand, grantBuilding } from "./framework/helpers.js";
|
||||
|
||||
@@ -51,12 +53,12 @@ export class GameScreenTest extends TestCase {
|
||||
const territory = screen.map.territory;
|
||||
const regions = screen.map.regions;
|
||||
const ethnicity = screen._tileEthnicity;
|
||||
const baseline = screen._gdpBaseline;
|
||||
const baseline = screen._productionBaseline;
|
||||
// A second snapshot with no state change carries the same versions, so
|
||||
// none of the big per-tile maps should be rebuilt.
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assert(population === screen._population, "population map is reused");
|
||||
this.assert(baseline === screen._gdpBaseline, "the growth baseline is reused");
|
||||
this.assert(baseline === screen._productionBaseline, "the growth baseline is reused");
|
||||
this.assert(territory === screen.map.territory, "territory map is reused");
|
||||
this.assert(regions === screen.map.regions, "region map is reused");
|
||||
this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused");
|
||||
@@ -77,7 +79,7 @@ export class GameScreenTest extends TestCase {
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
grantBuilding(state, capital, "barracks");
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertEqual(env.$("#civ-name").text(), "France");
|
||||
this.assertEqual(env.$("#civ-name").text(), state.civilisations[0].name);
|
||||
this.assertEqual(env.$("#player-name").text(), "Carol");
|
||||
this.assertTrue(env.$("#stat-pop").text() !== "0");
|
||||
this.assertTrue(env.$("#stat-date").text().includes("2000"));
|
||||
@@ -616,37 +618,21 @@ export class GameScreenTest extends TestCase {
|
||||
screen.map.onTileRequested(capital.coords);
|
||||
this.assertFalse(env.$("#tile-info").hasClass("hidden"));
|
||||
this.assertTrue(env.$("#tile-coords").text().includes("Tile"));
|
||||
this.assertTrue(env.$("#tile-gdp").text().includes("GDP/capita"));
|
||||
// The panel leads with what the tile produces, and GDP is only shown for
|
||||
// its region.
|
||||
this.assertTrue(
|
||||
env.$("#tile-gdp").text().includes("Capital"),
|
||||
"the tile panel explains the capital GDP bonus"
|
||||
env.$("#tile-gdp").text().includes("Region GDP/capita"),
|
||||
"GDP is shown at the region level"
|
||||
);
|
||||
this.assertGreater(
|
||||
env.$("#tile-production .tile-prod-row").length,
|
||||
0,
|
||||
"the tile panel lists the resources it produces"
|
||||
);
|
||||
this.assertTrue(
|
||||
env.$("#tile-production").text().includes("Food"),
|
||||
"the capital's people grow food"
|
||||
);
|
||||
|
||||
screen._renderTileGdp({
|
||||
base: 10000,
|
||||
factors: [
|
||||
{ label: "Terrain", multiplier: 1 },
|
||||
{ label: "City", multiplier: 2 },
|
||||
{ label: "Battle damage", amount: 0 },
|
||||
],
|
||||
total: 20000,
|
||||
});
|
||||
const gdpText = env.$("#tile-gdp").text();
|
||||
this.assertFalse(gdpText.includes("Terrain"), "a no-op multiplier is hidden");
|
||||
this.assertFalse(gdpText.includes("Battle damage"), "a zero modifier is hidden");
|
||||
this.assertTrue(gdpText.includes("City"), "a real modifier is still shown");
|
||||
|
||||
screen._renderTileGdp({
|
||||
base: 10000,
|
||||
factors: [
|
||||
{ label: "Natural growth", amount: 150 },
|
||||
{ label: "Battle damage", amount: -50 },
|
||||
],
|
||||
total: 10100,
|
||||
});
|
||||
const amountText = env.$("#tile-gdp").text();
|
||||
this.assertTrue(amountText.includes("Natural growth +€150"), "a gain shows a plus");
|
||||
this.assertTrue(amountText.includes("Battle damage −€50"), "a loss keeps its minus");
|
||||
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
screen.map.onUnitSelected(unit.id);
|
||||
@@ -1015,7 +1001,7 @@ export class GameScreenTest extends TestCase {
|
||||
await screen.map.onMoveOrdered([unit.id], goal);
|
||||
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
|
||||
this.assertEmpty(network.orders, "nothing is sent before confirming");
|
||||
this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
|
||||
this.assertTrue(env.$("#confirm-message").text().includes(state.civilisations[1].name));
|
||||
|
||||
env.$("#confirm-ok").click();
|
||||
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] });
|
||||
@@ -1079,7 +1065,7 @@ export class GameScreenTest extends TestCase {
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
|
||||
const row = env.$("#diplomacy-list tbody tr").first();
|
||||
const row = env.$("#diplomacy-list tbody tr:not(.local-nation)").first();
|
||||
this.assertTrue(row.find(".name").text().length > 0);
|
||||
row.find("button").click();
|
||||
this.assertEqual(network.orders[0].type, "declare_war");
|
||||
@@ -1124,7 +1110,7 @@ export class GameScreenTest extends TestCase {
|
||||
screen.onState(state.snapshot(0));
|
||||
env.$("#news-button").click();
|
||||
this.assertFalse(env.$("#modal-news").hasClass("hidden"));
|
||||
this.assertTrue(env.$("#news-list").text().includes("declared war on Britain"));
|
||||
this.assertTrue(env.$("#news-list").text().includes(`declared war on ${state.civilisations[1].name}`));
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
@@ -1144,7 +1130,7 @@ export class GameScreenTest extends TestCase {
|
||||
state.requestDeclareWar(0, 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertSize(env.$("#game-feed .feed-line"), 1);
|
||||
this.assertTrue(env.$("#game-feed .feed-line").text().includes("declared war on Britain"));
|
||||
this.assertTrue(env.$("#game-feed .feed-line").text().includes(`declared war on ${state.civilisations[1].name}`));
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
@@ -1530,7 +1516,7 @@ export class GameScreenTest extends TestCase {
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
|
||||
!state.roads.has(k) && !state.railways.has(k);
|
||||
!state.roads.has(k) && !state.railways.has(k) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
screen._showTile(coords);
|
||||
@@ -1538,12 +1524,32 @@ export class GameScreenTest extends TestCase {
|
||||
this.assertEqual(options.length, 2, "road and railway are listed");
|
||||
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
|
||||
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
|
||||
// Each is a square icon with its name in the tooltip and cost beneath.
|
||||
// Each is a square icon with its name in the tooltip and its resource and
|
||||
// money cost beneath.
|
||||
this.assertTrue(
|
||||
options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
|
||||
"the road button shows its icon"
|
||||
);
|
||||
this.assertEqual(options.eq(0).find(".improvement-cost").text(), "€50M", "cost under the icon");
|
||||
const costText = options.eq(0).find(".improvement-cost").text();
|
||||
this.assertTrue(costText.includes("5000 t"), `the steel cost is shown: ${costText}`);
|
||||
this.assertTrue(costText.includes("100 GWh"), `the energy cost is shown: ${costText}`);
|
||||
// The money figure is the build cost plus the material bill at today's
|
||||
// market prices, not the raw build cost.
|
||||
const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
|
||||
const expectedCost = quotedBuildCost(
|
||||
roadProto.buildCost,
|
||||
tileImprovementResourceCost(roadProto),
|
||||
marketPricesFrom(screen.snapshot)
|
||||
);
|
||||
this.assertTrue(
|
||||
costText.includes(`£${compact(expectedCost)}`),
|
||||
`the market price is shown: ${costText}`
|
||||
);
|
||||
this.assertEqual(
|
||||
options.eq(0).find(".improvement-cost img.cost-icon").length,
|
||||
2,
|
||||
"steel and energy carry resource icons"
|
||||
);
|
||||
this.assertTrue(
|
||||
options.eq(0).attr("title").startsWith("Road"),
|
||||
"the name is the tooltip"
|
||||
@@ -1556,10 +1562,16 @@ export class GameScreenTest extends TestCase {
|
||||
this.assertEqual(order.improvement, "road");
|
||||
|
||||
// A tile that already carries the improvement greys it out, keeps the
|
||||
// upgrade and offers a remove control.
|
||||
// upgrade and offers a remove control. The seeded starter industry also
|
||||
// puts works on network tiles, so clear any off this one first.
|
||||
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
|
||||
const roadKey = `${road[0]},${road[1]}`;
|
||||
state.tileImprovements.delete(roadKey);
|
||||
state.tileImprovementOwner.delete(roadKey);
|
||||
state.tileImprovementHp.delete(roadKey);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._showTile({ x: road[0], y: road[1] });
|
||||
const again = env.$("#tile-improvement-actions .improvement-btn").not(".remove");
|
||||
const again = env.$("#tile-improvement-actions .transport-btn").not(".remove");
|
||||
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
|
||||
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
|
||||
const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
|
||||
@@ -1575,6 +1587,71 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_improvement_buttons_are_grouped_by_category() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
screen.onState(state.snapshot(0));
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
screen._showTile(coords);
|
||||
const labels = env.$("#tile-improvement-actions .improvement-category")
|
||||
.map((_, el) => env.$(el).text())
|
||||
.get();
|
||||
this.assertEqual(
|
||||
labels.join(","),
|
||||
"Military,Transportation,Economic",
|
||||
"the categories are labelled in order"
|
||||
);
|
||||
const firstClasses = env.$("#tile-improvement-actions .improvement-row")
|
||||
.map((_, el) => env.$(el).find(".improvement-btn").first().attr("class"))
|
||||
.get();
|
||||
this.assertTrue(firstClasses[0].includes("tile-btn"), "military works come first");
|
||||
this.assertTrue(firstClasses[1].includes("transport-btn"), "transport comes second");
|
||||
this.assertTrue(firstClasses[2].includes("resource-btn"), "economic producers come last");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_improvement_cost_tracks_market_prices() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._showTile(coords);
|
||||
const before = env.$("#tile-improvement-actions .transport-btn").eq(0)
|
||||
.find(".improvement-cost").text();
|
||||
// A jump in the world steel price raises the quoted price, without
|
||||
// changing the amount of steel shown.
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 3);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._showTile(coords);
|
||||
const after = env.$("#tile-improvement-actions .transport-btn").eq(0)
|
||||
.find(".improvement-cost").text();
|
||||
this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
|
||||
this.assertTrue(after.includes("5000 t"), "the steel amount is unchanged");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_tile_panel_shows_improvement_build_progress() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
@@ -1584,7 +1661,8 @@ export class GameScreenTest extends TestCase {
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.unitAt(cell);
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
|
||||
!state.unitAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
this.assertTrue(
|
||||
@@ -1765,7 +1843,7 @@ export class GameScreenTest extends TestCase {
|
||||
const sign = expected < 0 ? "−" : "+";
|
||||
this.assertEqual(
|
||||
env.$("#stat-budget").closest(".stat").attr("data-tooltip"),
|
||||
`${sign}€${compact(Math.abs(expected))}/mo`
|
||||
`${sign}£${compact(Math.abs(expected))}/mo`
|
||||
);
|
||||
// Popularity speaks through the same bubble, not a native title.
|
||||
const $approval = env.$("#stat-approval").closest(".stat");
|
||||
@@ -1777,33 +1855,23 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_tile_gdp_breakdown_includes_natural_growth() {
|
||||
async test_the_tile_panel_lists_produced_resources() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.totalHours = 10 * HOURS_PER_DAY;
|
||||
screen.onState(state.snapshot(0));
|
||||
const k = state.tileGdpBaseline.keys().next().value;
|
||||
const coords = parseKey(k);
|
||||
const shipped = Math.round(state.tileGdpBaseline.get(k));
|
||||
const expected = shipped * ECONOMY.naturalGdpGrowthPerDay * 10;
|
||||
this.assertApprox(
|
||||
screen._naturalTileGdpGrowth(k),
|
||||
expected,
|
||||
1e-6,
|
||||
"the browser mirrors the server's growth"
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen._showTile({ x: capital.coords.x, y: capital.coords.y });
|
||||
const $food = env.$("#tile-production .tile-prod-row").filter((_, el) =>
|
||||
env.$(el).text().includes("Food")
|
||||
);
|
||||
const breakdown = screen._tileGdpBreakdown(
|
||||
coords,
|
||||
0,
|
||||
state.cityAt(coords),
|
||||
screen.map.getTile(coords)
|
||||
);
|
||||
const factor = breakdown.factors.find((f) => f.label === "Natural growth");
|
||||
this.assertNotNull(factor, "the breakdown names the growth");
|
||||
this.assertApprox(factor.amount, expected, 1e-6, "and adds it to the total");
|
||||
this.assertEqual($food.length, 1, "the capital grows food");
|
||||
this.assertTrue($food.text().includes("/day"), "its output is per day");
|
||||
// The modifiers that shape the output ride along in the row's tooltip.
|
||||
this.assertNotNull($food.attr("title"), "the row explains its modifiers");
|
||||
this.assertTrue($food.attr("title").includes("Capital"), `cites the capital bonus: ${$food.attr("title")}`);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
|
||||
@@ -416,7 +416,7 @@ export class GameServerOrdersTest extends TestCase {
|
||||
const choose = network.sent.find((m) => m.peerId === 2 && m.message.t === "choose_civ");
|
||||
this.assertNotNull(choose, "the joiner is offered a choice");
|
||||
this.assertEqual(choose.message.civs.map((c) => c.index), [1]);
|
||||
this.assertEqual(choose.message.civs[0].id, "britain");
|
||||
this.assertEqual(choose.message.civs[0].id, server.state.civilisations[1].id);
|
||||
this.assertEqual(server.peerCiv.get(2), -1, "no nation until the player picks");
|
||||
}
|
||||
|
||||
@@ -585,7 +585,7 @@ export class GameServerLobbyTest extends TestCase {
|
||||
this.assertEqual(game.seed, SEED);
|
||||
this.assertEqual(game.id, SEED);
|
||||
this.assertSize(game.civs, 2);
|
||||
this.assertEqual(game.civs[0], "France");
|
||||
this.assertEqual(game.civs.slice().sort(), ["Britain", "France"]);
|
||||
this.assertEqual(game.players, 0);
|
||||
this.assertTrue(game.date.includes("2000"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH } from "../shared/data.js";
|
||||
import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { tileFoodOutput } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
@@ -116,17 +118,54 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
return tile && tile.terrainClass === "Sea";
|
||||
}).length;
|
||||
this.assertGreater(sea, 0, "the city touches the sea");
|
||||
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
|
||||
// A naval city already starts with one port level.
|
||||
this.assertEqual(city.buildings[portIndex], 1, "the seeded port is there");
|
||||
city.buildings[portIndex] = 0;
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const sample = state.regionTiles(city).find((coords) => !state.cityAt(coords)) || state.regionTiles(city)[0];
|
||||
// Isolate the port's GDP bonus: the port also lifts food output, which
|
||||
// feeds back into the production-based base. That is tested separately.
|
||||
const foodMultiplier = state._regionFoodMultiplier;
|
||||
state._regionFoodMultiplier = () => 1;
|
||||
const before = state.getTileGdpPerCapita(sample);
|
||||
|
||||
grantBuilding(state, city, "port", 2);
|
||||
city.buildings[portIndex] = 2;
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const after = state.getTileGdpPerCapita(sample);
|
||||
state._regionFoodMultiplier = foodMultiplier;
|
||||
|
||||
// +1000 per port level for each neighbouring sea tile, on every region tile.
|
||||
this.assertApprox(after - before, 1000 * sea * 2, 1e-6, "the port lifts the whole region");
|
||||
}
|
||||
|
||||
test_port_lifts_its_regions_food_output() {
|
||||
const state = smallState();
|
||||
const city = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
|
||||
this.assertNotNull(city, "the nation has a coastal city");
|
||||
const sea = state.topology
|
||||
.neighbours(city.coords.x, city.coords.y)
|
||||
.filter((n) => {
|
||||
const tile = state.tiles[key(n.x, n.y)];
|
||||
return tile && tile.terrainClass === "Sea";
|
||||
}).length;
|
||||
this.assertGreater(sea, 0, "the city touches the sea");
|
||||
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
|
||||
city.buildings[portIndex] = 0;
|
||||
const before = state._regionProductionValue(city);
|
||||
city.buildings[portIndex] = 1;
|
||||
const after = state._regionProductionValue(city);
|
||||
// The port adds 5% of the region's food output for each adjacent sea tile.
|
||||
const foodValue = state.regionTiles(city).reduce((sum, coords) => {
|
||||
const k = key(coords.x, coords.y);
|
||||
const tile = state.tiles[k];
|
||||
const population = state.tilePopulation.get(k) || 0;
|
||||
if (population <= 0) return sum;
|
||||
return sum + tileFoodOutput(tile) * state.getResourcePrice("food");
|
||||
}, 0) * DAYS_PER_YEAR;
|
||||
this.assertApprox(after - before, foodValue * 0.05 * sea, foodValue * 1e-6, "the port grows more food");
|
||||
}
|
||||
|
||||
test_request_build_charges_budget_and_raises_the_level() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
@@ -176,11 +215,13 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertEqual(state.getCityBuildingLevel(city, index), 2);
|
||||
}
|
||||
|
||||
test_request_build_rejects_when_unaffordable() {
|
||||
test_request_build_goes_ahead_on_an_empty_treasury() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
const index = this.addBuilding(state, gdpBuilding());
|
||||
this.assertFalse(state.requestBuild(cityOf(state, 0).id, index));
|
||||
// The government can always pay, running an overdraft with the central bank.
|
||||
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
|
||||
this.assertLess(state.getBudget(0), 0, "the treasury went into debt");
|
||||
}
|
||||
|
||||
test_request_build_has_no_level_cap() {
|
||||
@@ -218,17 +259,19 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish");
|
||||
}
|
||||
|
||||
test_building_upkeep_is_added_to_player_upkeep() {
|
||||
test_building_upkeep_is_added_to_material_upkeep() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const index = this.addBuilding(
|
||||
state,
|
||||
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0 })
|
||||
);
|
||||
const before = state.getPlayerUpkeep(0);
|
||||
const before = state.getBuildingUpkeep(0);
|
||||
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
|
||||
this.advanceUntilIdle(state);
|
||||
this.assertApprox(state.getPlayerUpkeep(0), before + 100);
|
||||
// 5e-7 t of steel and 1e-3 kWh of energy per euro of the level's cost.
|
||||
this.assertApprox(state.getBuildingUpkeep(0).steel, before.steel + 0.005, 1e-9);
|
||||
this.assertApprox(state.getBuildingUpkeep(0).energy, before.energy + 10, 1e-9);
|
||||
}
|
||||
|
||||
test_research_buildings_accumulate_points_each_hour() {
|
||||
@@ -264,16 +307,17 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertHas(breakdown, "buildingUpkeep");
|
||||
this.assertHas(breakdown, "unitUpkeep");
|
||||
this.assertHas(breakdown, "transportUpkeep");
|
||||
this.assertApprox(breakdown.buildingUpkeep.total, 100);
|
||||
this.assertApprox(
|
||||
breakdown.unitUpkeep.total,
|
||||
state.getPlayerUpkeep(0) -
|
||||
breakdown.buildingUpkeep.total -
|
||||
breakdown.campaignUpkeep.total -
|
||||
breakdown.transportUpkeep.total
|
||||
);
|
||||
const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
||||
this.assertApprox(breakdown.income.total, expected);
|
||||
// Upkeep lines are the market value of the materials the nation must buy.
|
||||
const expected = state._upkeepCost(state.getBuildingUpkeep(0));
|
||||
this.assertApprox(breakdown.buildingUpkeep.total, expected, 1e-6);
|
||||
// The only direct money upkeep left is propaganda.
|
||||
this.assertApprox(state.getPlayerUpkeep(0), breakdown.campaignUpkeep.total, 1e-9);
|
||||
const expected_net = breakdown.income.total - breakdown.buildingUpkeep.total -
|
||||
breakdown.unitUpkeep.total - breakdown.campaignUpkeep.total -
|
||||
breakdown.transportUpkeep.total - breakdown.tileUpkeep.total;
|
||||
this.assertApprox(breakdown.net, expected_net, 1e-6);
|
||||
const income = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
|
||||
this.assertApprox(breakdown.income.total, income, 1e-6);
|
||||
}
|
||||
|
||||
test_budget_breakdown_names_income_sources() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { smallState, stripPorts } from "./framework/helpers.js";
|
||||
import { GOVERNMENTS } from "../shared/data.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
@@ -30,14 +30,19 @@ export class GameStateGovernmentTest extends TestCase {
|
||||
this.assertFalse(state.requestSetGovernment(999, 1), "unknown civilisation");
|
||||
}
|
||||
|
||||
test_request_set_government_rejects_when_unaffordable() {
|
||||
test_request_set_government_goes_ahead_on_an_empty_treasury() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
this.assertFalse(state.requestSetGovernment(0, 1));
|
||||
// The government can always pay, borrowing from the central bank.
|
||||
this.assertTrue(state.requestSetGovernment(0, 1));
|
||||
this.assertLess(state.getBudget(0), 0, "the adoption cost became debt");
|
||||
}
|
||||
|
||||
test_government_effects_are_folded_into_gdp() {
|
||||
const state = smallState();
|
||||
// Ports add a flat GDP that is not scaled by the government, so strip the
|
||||
// seeded ones to keep the government multiplier the only variable.
|
||||
stripPorts(state, 0);
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const monarchy = state.governments.findIndex((g) => g.id === "monarchy");
|
||||
const republicGdp = state.getPlayerGdp(0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { smallState, stripPorts } from "./framework/helpers.js";
|
||||
import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
|
||||
import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
|
||||
|
||||
@@ -104,6 +104,7 @@ export class GameStateTechnologyTest extends TestCase {
|
||||
|
||||
test_technology_effects_are_folded_into_gdp() {
|
||||
const state = smallState();
|
||||
stripPorts(state, 0);
|
||||
const before = state.getPlayerGdp(0);
|
||||
this.complete(state, "scientific_method");
|
||||
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
|
||||
@@ -188,4 +189,11 @@ export class GameStateTechnologyTest extends TestCase {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test_research_costs_a_hundred_times_the_catalogue_price() {
|
||||
const state = smallState();
|
||||
const index = this.index(state, "scientific_method");
|
||||
const proto = TECHNOLOGIES[index];
|
||||
this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,8 @@ export class GameStateTest extends TestCase {
|
||||
const state = smallState();
|
||||
this.assertGreater(state.getPlayerPopulation(0), 0);
|
||||
this.assertGreater(state.getPlayerGdp(0), 0);
|
||||
this.assertGreater(state.getPlayerUpkeep(0), 0);
|
||||
// Upkeep is now materials, not money: a starting army wears out steel.
|
||||
this.assertGreater(state.getCivUpkeepResources(0).steel, 0);
|
||||
this.assertApprox(
|
||||
state.getBudget(0),
|
||||
state.getPlayerGdp(0) * ECONOMY.startingBudgetFraction,
|
||||
@@ -128,7 +129,7 @@ export class GameStateTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() {
|
||||
test_civilisations_start_with_equal_cities_and_population() {
|
||||
const state = smallState(["france", "britain", "slovenia"]);
|
||||
const count = state.civilisations.length;
|
||||
const cities = [];
|
||||
@@ -142,8 +143,14 @@ export class GameStateTest extends TestCase {
|
||||
}
|
||||
this.assertEqual(cities, [2, 2, 2], "every civ starts with the same number of cities");
|
||||
this.assertEqual(populations, [populations[0], populations[0], populations[0]], "every civ starts with the same population");
|
||||
// GDP is the market value of what the land and its seeded works produce, so
|
||||
// terrain makes it differ a little between nations; the balance keeps them
|
||||
// within a narrow band rather than exactly equal.
|
||||
for (const perCapita of perCapitas) {
|
||||
this.assertApprox(perCapita, perCapitas[0], 1e-6, "every civ starts with the same GDP per capita");
|
||||
this.assertTrue(
|
||||
Math.abs(perCapita - perCapitas[0]) <= perCapitas[0] * 0.15,
|
||||
"every civ starts with a comparable GDP per capita"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,36 +4,42 @@ import { ECONOMY } from "../shared/data.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { parseKey } from "../shared/hex.js";
|
||||
|
||||
// Natural GDP-per-capita growth: a constant daily amount measured from each
|
||||
// Natural production growth: a constant daily amount measured from each
|
||||
// tile's opening figure, so the relative growth rate tapers over the years.
|
||||
export class GdpGrowthTest extends TestCase {
|
||||
firstBaseline(state) {
|
||||
const k = state.tileGdpBaseline.keys().next().value;
|
||||
return { k, baseline: state.tileGdpBaseline.get(k), coords: parseKey(k) };
|
||||
const k = state.tileProductionBaseline.keys().next().value;
|
||||
return { k, baseline: state.tileProductionBaseline.get(k), coords: parseKey(k) };
|
||||
}
|
||||
|
||||
test_a_day_adds_a_constant_share_of_the_opening_figure() {
|
||||
const state = smallState();
|
||||
// Measure natural growth alone; migration would move people between regions
|
||||
// and shift the per-capita on its own.
|
||||
state._migrateForIncome = () => {};
|
||||
const { k, baseline, coords } = this.firstBaseline(state);
|
||||
this.assertTrue(baseline > 0, "the tile has an opening GDP per capita");
|
||||
this.assertTrue(baseline > 0, "the tile has an opening production per head");
|
||||
const opening = state.getTileGdpPerCapita(coords);
|
||||
this.assertApprox(opening, baseline, 1e-9, "day zero is the baseline");
|
||||
for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour();
|
||||
const dayOne = state.getTileGdpPerCapita(coords);
|
||||
const step = baseline * ECONOMY.naturalGdpGrowthPerDay;
|
||||
this.assertApprox(dayOne - opening, step, 1e-6, "one day adds one step");
|
||||
// Thirty more days add exactly thirty more steps: the amount is constant.
|
||||
const step = baseline * ECONOMY.naturalProductionGrowthPerDay;
|
||||
this.assertTrue(dayOne > opening, "the tile has grown after a day");
|
||||
// The total also tracks market prices, which drift as production meets
|
||||
// demand; the natural-growth term itself is what adds exactly one step.
|
||||
this.assertApprox(state._naturalTileProductionGrowth(k), step, 1e-6, "one day adds one step");
|
||||
// Thirty more days add exactly thirty more steps: the natural-growth term is
|
||||
// a constant. The tile's total also tracks production and market prices,
|
||||
// which drift on their own, so the term itself is what stays linear.
|
||||
state.totalHours = 31 * HOURS_PER_DAY;
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const dayThirtyOne = state.getTileGdpPerCapita(coords);
|
||||
this.assertApprox(dayThirtyOne - dayOne, 30 * step, 1e-6, "the daily amount never grows");
|
||||
this.assertEqual(k, state.tileGdpBaseline.keys().next().value, "the baseline never moves");
|
||||
this.assertApprox(state._naturalTileProductionGrowth(k), 31 * step, 1e-6, "the daily amount never grows");
|
||||
this.assertEqual(k, state.tileProductionBaseline.keys().next().value, "the baseline never moves");
|
||||
}
|
||||
|
||||
test_the_relative_growth_rate_falls_as_the_tile_grows() {
|
||||
const state = smallState();
|
||||
const { baseline, coords } = this.firstBaseline(state);
|
||||
const step = baseline * ECONOMY.naturalGdpGrowthPerDay;
|
||||
const step = baseline * ECONOMY.naturalProductionGrowthPerDay;
|
||||
const earlyRate = step / baseline;
|
||||
state.totalHours = 366 * HOURS_PER_DAY;
|
||||
state._gdpPerCapitaCache.clear();
|
||||
@@ -47,6 +53,8 @@ export class GdpGrowthTest extends TestCase {
|
||||
|
||||
test_growth_is_measured_from_january_2000_in_the_first_year() {
|
||||
const state = smallState();
|
||||
// Hold migration still so the day's change is natural growth alone.
|
||||
state._migrateForIncome = () => {};
|
||||
const growth = state.viewerStats(0).growth;
|
||||
this.assertNotNull(growth, "the viewer carries growth figures");
|
||||
this.assertTrue(growth.sinceStart, "the first year measures from January 2000");
|
||||
@@ -85,12 +93,12 @@ export class GdpGrowthTest extends TestCase {
|
||||
const state = smallState();
|
||||
const first = state.snapshot(0);
|
||||
this.assertSize(
|
||||
first.gdpBaseline,
|
||||
state.tileGdpBaseline.size,
|
||||
first.productionBaseline,
|
||||
state.tileProductionBaseline.size,
|
||||
"every baseline tile travels"
|
||||
);
|
||||
this.assertNotNull(first.viewerStats.growth, "the viewer's growth travels too");
|
||||
// The version never moves, so the delta wire drops the baseline afterwards.
|
||||
this.assertEqual(first.versions.gdpBaseline, 0, "the baseline version is static");
|
||||
this.assertEqual(first.versions.productionBaseline, 0, "the baseline version is static");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { TRANSPORT_BY_ID } from "../shared/data/improvements.js";
|
||||
import { TRANSPORT_BY_ID, TILE_IMPROVEMENT_BY_ID } from "../shared/data/improvements.js";
|
||||
import { RESOURCE_RULES } from "../shared/data/resources.js";
|
||||
import { tileImprovementResourceCost } from "../shared/resources.js";
|
||||
|
||||
const BIG_BUDGET = 10_000_000_000;
|
||||
|
||||
@@ -14,9 +16,14 @@ function bareOwnedLand(state, civ = 0) {
|
||||
}
|
||||
|
||||
export class ImprovementsTest extends TestCase {
|
||||
test_building_a_road_costs_money_and_lays_the_tile() {
|
||||
test_building_a_road_costs_money_and_resources_and_lays_the_tile() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
// Stock enough steel that only the electricity is bought on the market, so
|
||||
// the money bill is the build cost plus the construction energy.
|
||||
for (const city of state.cities) {
|
||||
if (city.civ === 0) state.getCityResourceStock(city).steel = 1e9;
|
||||
}
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertNotNull(coords, "the map has a bare owned land tile");
|
||||
const k = key(coords.x, coords.y);
|
||||
@@ -24,7 +31,14 @@ export class ImprovementsTest extends TestCase {
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertTrue(state.roads.has(k), "the road is on the tile");
|
||||
this.assertFalse(state.railways.has(k), "it is not a railway");
|
||||
this.assertApprox(before - state.getBudget(0), TRANSPORT_BY_ID.road.buildCost);
|
||||
const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road);
|
||||
const energyCost = materials.energy * state.getResourcePrice("energy");
|
||||
this.assertApprox(
|
||||
before - state.getBudget(0),
|
||||
TRANSPORT_BY_ID.road.buildCost + energyCost,
|
||||
1e-3,
|
||||
"money plus the construction electricity"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_railway_replaces_a_road_and_moves_twice_as_fast() {
|
||||
@@ -95,12 +109,14 @@ export class ImprovementsTest extends TestCase {
|
||||
this.assertApprox(state.getBudget(0), after, 1e-9, "nothing was charged");
|
||||
}
|
||||
|
||||
test_poor_nations_cannot_build() {
|
||||
test_a_road_can_be_built_on_an_empty_treasury() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertFalse(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertFalse(state.roads.has(key(coords.x, coords.y)));
|
||||
// The government can always pay, so the road goes ahead on borrowed money.
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
|
||||
this.assertLess(state.getBudget(0), 0, "the cost became central-bank debt");
|
||||
}
|
||||
|
||||
test_maintenance_counts_roads_and_railways_the_nation_holds() {
|
||||
@@ -112,16 +128,16 @@ export class ImprovementsTest extends TestCase {
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
TRANSPORT_BY_ID.road.maintenance,
|
||||
RESOURCE_RULES.transportUpkeepSteel.road,
|
||||
1e-9,
|
||||
"a road adds its hourly maintenance"
|
||||
"a road adds its daily steel wear"
|
||||
);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
TRANSPORT_BY_ID.railway.maintenance,
|
||||
RESOURCE_RULES.transportUpkeepSteel.railway,
|
||||
1e-9,
|
||||
"the railway replaces the road's maintenance"
|
||||
"the railway replaces the road's wear"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,4 +192,37 @@ export class ImprovementsTest extends TestCase {
|
||||
this.assertTrue(state.railways.has(k), "a city carries a railway");
|
||||
this.assertFalse(state.roads.has(k), "not a road");
|
||||
}
|
||||
|
||||
test_a_tile_work_costs_steel_energy_and_high_tech() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
// Enough stores that only the electricity is bought, so the money bill is
|
||||
// the build cost plus the construction energy.
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
}
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = key(cell.x, cell.y);
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
// A radar tower is advanced, so it also spends high-tech.
|
||||
const radar = TILE_IMPROVEMENT_BY_ID.radar_tower;
|
||||
const materials = tileImprovementResourceCost(radar);
|
||||
this.assertGreater(materials.steel, 0, "steel is spent");
|
||||
this.assertGreater(materials.energy, 0, "energy is spent");
|
||||
this.assertGreater(materials.hightech, 0, "high-tech is spent");
|
||||
const before = state.getBudget(0);
|
||||
this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
|
||||
const energyCost = materials.energy * state.getResourcePrice("energy");
|
||||
this.assertApprox(
|
||||
before - state.getBudget(0),
|
||||
radar.buildCost + energyCost,
|
||||
1e-3,
|
||||
"money plus the construction electricity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export class MapGeneratorTest extends TestCase {
|
||||
this.assertHas(tile, "terrainClass");
|
||||
this.assertHas(tile, "movementCostMultiplier");
|
||||
this.assertHas(tile, "populationMultiplier");
|
||||
this.assertHas(tile, "gdpMultiplier");
|
||||
this.assertHas(tile, "productionMultiplier");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ export class MapModesTest extends TestCase {
|
||||
this.assertFalse(map.political, "the political fill is off");
|
||||
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
|
||||
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
|
||||
this.assertEqual(env.$("#map-legend-min").text(), "€100", "the legend shows the minimum");
|
||||
this.assertEqual(env.$("#map-legend-max").text(), "€1 234", "the legend shows the maximum");
|
||||
this.assertEqual(env.$("#map-legend-min").text(), "¤100", "the legend shows the minimum");
|
||||
this.assertEqual(env.$("#map-legend-max").text(), "¤1 234", "the legend shows the maximum");
|
||||
// Every land tile carries the top-of-range colour, so the fill is live.
|
||||
const top = economicColour(1234, 100, 1234);
|
||||
let painted = false;
|
||||
@@ -140,6 +140,25 @@ export class MapModesTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_delivery_graph_shows_only_in_economic_modes() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("terrain");
|
||||
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
|
||||
this.assertEmpty(env.$("#layer-resources .resource-node"), "hidden on the terrain map");
|
||||
this.assertEmpty(env.$("#layer-resources .border-seg"), "and no delivery legs either");
|
||||
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
|
||||
map.setMapMode("population");
|
||||
this.assertNotEmpty(env.$("#layer-resources .resource-node"), "shown in an economic mode");
|
||||
this.assertNotEmpty(env.$("#layer-resources .border-seg"), "the delivery leg is drawn");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_fog_rebuilds_are_coalesced_between_frames() {
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
|
||||
@@ -12,8 +12,14 @@ export class MapViewTest extends TestCase {
|
||||
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
|
||||
map.$viewport.width = () => 800;
|
||||
map.$viewport.height = () => 600;
|
||||
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
|
||||
const snapshot = state.snapshot(0);
|
||||
// The snapshot ships the nations in their seeded order; rebuild the full
|
||||
// catalogue in that same order so owner indices line up, exactly as the
|
||||
// game screen does.
|
||||
const catalogue = snapshot.civs.map(
|
||||
(c) => CIVILISATIONS.find((full) => full.id === c.id) || c
|
||||
);
|
||||
map.setCatalogue(catalogue, PROTO_UNITS);
|
||||
map.applySnapshot(snapshot);
|
||||
return { env, map, snapshot };
|
||||
}
|
||||
@@ -127,13 +133,14 @@ export class MapViewTest extends TestCase {
|
||||
|
||||
async test_unit_views_show_their_owners_flag() {
|
||||
const state = smallState();
|
||||
const { env } = await this.build(state);
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
const flags = env.$("#layer-entities .unit .unit-flag");
|
||||
this.assertSize(flags, state.units.length, "every unit carries a flag");
|
||||
const unit = state.units[0];
|
||||
const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id);
|
||||
const src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || "";
|
||||
const view = map._unitViews.get(unit.id);
|
||||
const src = (view && view.querySelector(".unit-flag").getAttribute("src")) || "";
|
||||
this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
|
||||
export class MigrationTest extends TestCase {
|
||||
test_food_output_comes_from_the_land_not_the_headcount() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const before = state._regionProductionValue(city);
|
||||
// Pack many more people onto the same land: the harvest must not change.
|
||||
for (const coords of state.regionTiles(city)) {
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tilePopulation.set(k, (state.tilePopulation.get(k) || 0) * 4 + 1);
|
||||
}
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const after = state._regionProductionValue(city);
|
||||
this.assertApprox(after, before, 1e-6, "the land yields the same however many live on it");
|
||||
}
|
||||
|
||||
test_regions_of_a_nation_open_with_equal_income_per_head() {
|
||||
const state = smallState();
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
const cities = state.cities.filter((city) => city.civ === civ);
|
||||
if (cities.length < 2) continue;
|
||||
const perHead = cities.map((city) => {
|
||||
const economy = state.getCityEconomy(city);
|
||||
return economy.population > 0 ? economy.gdp / economy.population : 0;
|
||||
});
|
||||
const highest = Math.max(...perHead);
|
||||
const lowest = Math.min(...perHead);
|
||||
this.assertTrue(
|
||||
highest > 0 && (highest - lowest) / highest <= 0.02,
|
||||
`regions of civ ${civ} open level`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test_moving_people_conserves_the_total() {
|
||||
const state = smallState();
|
||||
const cities = state.cities.filter((city) => city.civ === 0);
|
||||
this.assertGreaterOrEqual(cities.length, 2, "the fixture has two regions");
|
||||
const from = cities[0];
|
||||
const to = cities[1];
|
||||
const fromBefore = state.regionPopulation(from);
|
||||
const toBefore = state.regionPopulation(to);
|
||||
const moved = state._movePopulation(from, to, 100_000, false);
|
||||
this.assertApprox(moved, 100_000, 1);
|
||||
this.assertApprox(state.regionPopulation(from), fromBefore - moved, 1e-6);
|
||||
this.assertApprox(state.regionPopulation(to), toBefore + moved, 1e-6);
|
||||
}
|
||||
|
||||
test_people_move_toward_the_richer_region() {
|
||||
const state = smallState();
|
||||
const cities = state.cities.filter((city) => city.civ === 0);
|
||||
const rich = cities[0];
|
||||
const poor = cities[1];
|
||||
// Dense on one side, sparse on the other: with land-based food that makes
|
||||
// the sparse region the rich one.
|
||||
for (const coords of state.regionTiles(rich)) {
|
||||
state.tilePopulation.set(key(coords.x, coords.y), 1_000);
|
||||
}
|
||||
for (const coords of state.regionTiles(poor)) {
|
||||
state.tilePopulation.set(key(coords.x, coords.y), 5_000_000);
|
||||
}
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const richBefore = state.regionPopulation(rich);
|
||||
const poorBefore = state.regionPopulation(poor);
|
||||
state._migrateForIncome();
|
||||
this.assertGreater(state.regionPopulation(rich), richBefore, "people moved to the rich region");
|
||||
this.assertLess(state.regionPopulation(poor), poorBefore, "and left the poor one");
|
||||
}
|
||||
|
||||
test_a_region_only_moves_to_a_much_richer_one() {
|
||||
const state = smallState();
|
||||
// Migration levelled the regions at generation, so no one is twice as rich
|
||||
// as anyone else and nobody moves.
|
||||
const before = state.cities.map((city) => state.regionPopulation(city));
|
||||
state._migrateForIncome();
|
||||
const after = state.cities.map((city) => state.regionPopulation(city));
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
this.assertApprox(after[i], before[i], 1e-6, "nobody moved to a merely equal region");
|
||||
}
|
||||
}
|
||||
|
||||
test_an_airport_caps_its_daily_intake() {
|
||||
const state = smallState();
|
||||
const cities = state.cities;
|
||||
const from = cityOf(state, 0);
|
||||
const to = cities.find((city) => city.civ === 1);
|
||||
// Every region is dense and equally poor except `to`, which is sparse and
|
||||
// so far richer. Only `to` is worth moving to.
|
||||
for (const city of cities) {
|
||||
const people = city.id === to.id ? 1_000 : 5_000_000;
|
||||
for (const coords of state.regionTiles(city)) {
|
||||
state.tilePopulation.set(key(coords.x, coords.y), people);
|
||||
}
|
||||
}
|
||||
state._gdpPerCapitaCache.clear();
|
||||
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
|
||||
from.buildings[airport] = 1;
|
||||
to.buildings[airport] = 1;
|
||||
const fromBefore = state.regionPopulation(from);
|
||||
state._migrateForIncome();
|
||||
const lost = fromBefore - state.regionPopulation(from);
|
||||
this.assertGreater(lost, 0, "people flew to the rich region");
|
||||
// 1000 people per airport level a day, and the route's level is 1.
|
||||
this.assertTrue(lost <= 1_001, `the flight carried at most its capacity, lost ${lost}`);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
|
||||
import { buildingEffectText, describeNews } from "../client/js/modals/format.js";
|
||||
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS } from "../shared/data.js";
|
||||
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js";
|
||||
import { groupDigits } from "../shared/text_format.js";
|
||||
|
||||
export class CityModalTest extends TestCase {
|
||||
test_utility_buildings_describe_their_mechanic() {
|
||||
@@ -14,7 +15,7 @@ export class CityModalTest extends TestCase {
|
||||
const shelters = BUILDINGS.find((b) => b.id === "shelters");
|
||||
this.assertTrue(buildingEffectText(shelters, 2).includes("20%"), "the shelters show their protection");
|
||||
const market = BUILDINGS.find((b) => b.id === "market");
|
||||
this.assertTrue(buildingEffectText(market, 1).includes("GDP/capita"), "stats keep their effect text");
|
||||
this.assertTrue(buildingEffectText(market, 1).includes("production"), "stats keep their effect text");
|
||||
}
|
||||
showTrain(modal, queue = []) {
|
||||
modal.show(
|
||||
@@ -165,13 +166,15 @@ export class CityModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_unaffordable_upgrade_is_disabled() {
|
||||
async test_upgrade_stays_available_on_an_empty_treasury() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new CityModal();
|
||||
modal.show({ id: 1, name: "X" }, { budget: 0, buildings: BUILDINGS, levels: {} });
|
||||
const upgrade = env.$("#city-buildings-list .row-card").first().find(".actions button").first();
|
||||
this.assertTrue(upgrade.prop("disabled"));
|
||||
// The government can always pay, running an overdraft, so cost never
|
||||
// disables the button; only an unmet requirement does.
|
||||
this.assertFalse(upgrade.prop("disabled"));
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
@@ -208,13 +211,13 @@ export class CityModalTest extends TestCase {
|
||||
};
|
||||
modal.show({ id: 1, name: "Paris" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {} });
|
||||
modal.renderBudget(breakdown);
|
||||
this.assertTrue(env.$("#city-budget-sub").text().includes("+€635/h"), "the region net is shown");
|
||||
this.assertTrue(env.$("#city-budget-sub").text().includes("+¤635/h"), "the region net is shown");
|
||||
const text = env.$("#city-budget-list").text();
|
||||
this.assertTrue(text.includes("+€1 035/h"), "region tax income is green");
|
||||
this.assertTrue(text.includes("+¤1 035/h"), "region tax income is green");
|
||||
this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
|
||||
this.assertTrue(text.includes("−€400/h"), "the building upkeep is listed");
|
||||
this.assertTrue(text.includes("−¤400/h"), "the building upkeep is listed");
|
||||
this.assertTrue(text.includes("TOTAL"), "a total row is present");
|
||||
this.assertTrue(text.includes("+€635/h"), "the total carries the region net");
|
||||
this.assertTrue(text.includes("+¤635/h"), "the total carries the region net");
|
||||
this.assertFalse(text.includes("No units"), "unit upkeep is national, not regional");
|
||||
|
||||
env.$("#city-budget-list tr.budget-group .budget-name").click();
|
||||
@@ -244,6 +247,36 @@ export class CityModalTest extends TestCase {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_economy_tab_shows_the_regions_cash_basket() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new CityModal();
|
||||
modal.show(
|
||||
{ id: 1, name: "Lyon" },
|
||||
{
|
||||
budget: 1.0e9,
|
||||
gdp: 1.0e9,
|
||||
cash: { FRF: 4000, GBP: 250 },
|
||||
cashValue: 4250,
|
||||
debt: 250,
|
||||
currencies: [
|
||||
{ code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
{ code: "GBP", symbol: "£", name: "British Pound" },
|
||||
],
|
||||
}
|
||||
);
|
||||
env.$("#modal-city .tab[data-tab=economy]").click();
|
||||
this.assertTrue(env.$("#city-tab-economy").hasClass("active"));
|
||||
const text = env.$("#city-economy-list").text();
|
||||
this.assertTrue(text.includes("French Franc"), "the national currency is listed");
|
||||
this.assertTrue(text.includes("British Pound"), "a foreign holding is listed");
|
||||
this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
|
||||
this.assertTrue(env.$("#city-economy-sub").text().includes("Debt"), "the central-bank debt shows");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NationModalTest extends TestCase {
|
||||
@@ -334,6 +367,90 @@ export class NationModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_resources_tab_lists_stores_needs_and_the_market() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const summary = {
|
||||
stock: { steel: 1000, food: 2_000_000, luxury: 5000, hightech: 12 },
|
||||
need: { steel: 10, food: 20, luxury: 30, hightech: 0 },
|
||||
production: { steel: 5, food: 0, luxury: 0, hightech: 1 },
|
||||
consumed: { steel: 15, food: 25, luxury: 35, hightech: 2, energy: 1.1e9 },
|
||||
energy: { need: 1.0e9, supply: 2.0e9 },
|
||||
trade: { imports: { steel: 40 }, exports: { steel: 7 } },
|
||||
prices: {},
|
||||
};
|
||||
const market = [
|
||||
{ id: "energy", price: 0.003, supply: 1.0e9, demand: 2.0e9 },
|
||||
{ id: "steel", price: 600, supply: 100, demand: 200 },
|
||||
{ id: "food", price: 300, supply: 50, demand: 60 },
|
||||
{ id: "luxury", price: 60, supply: 0, demand: 10 },
|
||||
{ id: "hightech", price: 800, supply: 0, demand: 0 },
|
||||
];
|
||||
modal.show(
|
||||
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5,
|
||||
{}, null, [], null, null, { summary, market }
|
||||
);
|
||||
env.$("#modal-nation .tab[data-tab=resources]").click();
|
||||
this.assertTrue(env.$("#tab-resources").hasClass("active"), "the tab opens");
|
||||
const rows = env.$("#resources-list .resources-table tbody tr");
|
||||
this.assertSize(rows, 5, "one row for every resource");
|
||||
const energy = rows.eq(0);
|
||||
this.assertTrue(energy.find(".name").text().includes("Energy"), "energy leads the list");
|
||||
this.assertEqual(energy.find("td").eq(1).text(), "—", "energy has no store");
|
||||
const steel = rows.eq(1);
|
||||
this.assertTrue(steel.find(".name").text().includes("Steel"));
|
||||
this.assertTrue(steel.find("td").eq(2).text().includes("15"), "consumption is shown");
|
||||
this.assertTrue(steel.find("td").eq(4).text().includes("7"), "exports are shown");
|
||||
this.assertTrue(steel.find("td").eq(5).text().includes("40"), "imports are shown");
|
||||
this.assertTrue(steel.find("td").eq(6).text().includes("600"), "the steel price is shown");
|
||||
this.assertTrue(steel.find("td").eq(7).text().length > 0, "world supply is shown");
|
||||
modal.refresh(
|
||||
0, new Set(), 1.0e9, 1.0e15, 5, {}, null, null, null, null,
|
||||
{ summary, market }
|
||||
);
|
||||
this.assertSize(env.$("#resources-list .resources-table tbody tr"), 5, "refreshing keeps the rows");
|
||||
modal.close();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_resources_tab_ranks_major_exporters_and_importers() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const summary = {
|
||||
stock: { steel: 0, food: 0, luxury: 0, hightech: 0 },
|
||||
need: { steel: 0, food: 0, luxury: 0, hightech: 0 },
|
||||
production: { steel: 0, food: 0, luxury: 0, hightech: 0 },
|
||||
consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
|
||||
energy: { need: 0, supply: 0 },
|
||||
trade: { imports: {}, exports: {} },
|
||||
prices: {},
|
||||
};
|
||||
const trade = [
|
||||
{ civ: 0, imports: { steel: 10 }, exports: { steel: 100 } },
|
||||
{ civ: 1, imports: { steel: 50 }, exports: { steel: 5 } },
|
||||
];
|
||||
const civs = [{ name: "France" }, { name: "Britain" }];
|
||||
modal.show(
|
||||
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5,
|
||||
{}, null, [], null, null, { summary, market: [], trade, civs }
|
||||
);
|
||||
env.$("#modal-nation .tab[data-tab=resources]").click();
|
||||
env.$("#resources-subtabs .subtab[data-view=leaders]").click();
|
||||
const rows = env.$("#resource-trade-list .resources-table tbody tr");
|
||||
this.assertSize(rows, 5, "one row for every resource");
|
||||
const steel = rows.eq(1);
|
||||
this.assertTrue(steel.find("td").eq(1).text().includes("France"), "the major exporter leads");
|
||||
this.assertTrue(steel.find("td").eq(3).text().includes("Britain"), "the major importer leads");
|
||||
modal.close();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_focused_technology_shows_a_progress_bar() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
@@ -346,7 +463,11 @@ export class NationModalTest extends TestCase {
|
||||
});
|
||||
const card = env.$("#technology-list .row-card").eq(method);
|
||||
this.assertTrue(card.find(".tech-progress-track").length > 0, "the focus has a bar");
|
||||
this.assertEqual(card.find(".tech-progress-text").text(), "10 / 20", "X / Y beside it");
|
||||
this.assertEqual(
|
||||
card.find(".tech-progress-text").text(),
|
||||
`10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`,
|
||||
"X / Y beside it"
|
||||
);
|
||||
this.assertEqual(card.find(".actions button").text(), "Focused", "no cost on the button");
|
||||
modal.close();
|
||||
} finally {
|
||||
@@ -393,38 +514,49 @@ export class NationModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_budget_tab_shows_income_and_upkeep_breakdown() {
|
||||
async test_budget_tab_shows_monthly_income_and_expenses() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const month = (overrides) => ({
|
||||
monthIndex: 24000,
|
||||
isCurrent: true,
|
||||
label: "January 2000",
|
||||
income: 0,
|
||||
sales: 0,
|
||||
totalCash: 0,
|
||||
totalEconomic: 0,
|
||||
net: 0,
|
||||
expenses: [],
|
||||
resourceTrade: [],
|
||||
...overrides,
|
||||
});
|
||||
const finances = {
|
||||
income: {
|
||||
rate: 0.001,
|
||||
base: 900,
|
||||
modifier: 0.15,
|
||||
total: 1035,
|
||||
sources: [
|
||||
{ label: "Bank", kind: "building", effect: 0.05, amount: 45, cityId: 1, cityName: "Paris" },
|
||||
{ label: "Republic", kind: "government", effect: 0.1, amount: 90 },
|
||||
],
|
||||
},
|
||||
buildingUpkeep: {
|
||||
total: 400,
|
||||
sources: [{ label: "Bank", kind: "building", amount: 400 }],
|
||||
groups: [
|
||||
{
|
||||
index: 1,
|
||||
label: "Bank",
|
||||
amount: 400,
|
||||
cities: [{ cityId: 1, cityName: "Paris", level: 2, amount: 400 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
unitUpkeep: {
|
||||
total: 150,
|
||||
sources: [{ label: "Modern infantry", kind: "unit", amount: 150, count: 3 }],
|
||||
},
|
||||
net: 485,
|
||||
months: [
|
||||
month({
|
||||
monthIndex: 23999,
|
||||
isCurrent: false,
|
||||
label: "December 1999",
|
||||
income: 500,
|
||||
totalCash: 400,
|
||||
totalEconomic: 1200,
|
||||
net: 100,
|
||||
expenses: [["buildings", 400, 1200]],
|
||||
}),
|
||||
month({
|
||||
income: 1035,
|
||||
sales: 60,
|
||||
totalCash: 550,
|
||||
totalEconomic: 900,
|
||||
net: 545,
|
||||
expenses: [
|
||||
["buildings", 400, 700, [["steel", 250], ["hightech", 150]]],
|
||||
["units", 150, 200, []],
|
||||
],
|
||||
resourceTrade: [["steel", 220, 0], ["food", 0, 90]],
|
||||
}),
|
||||
],
|
||||
cities: [],
|
||||
};
|
||||
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, finances);
|
||||
env.$("#modal-nation .tab[data-tab=budget]").click();
|
||||
@@ -432,31 +564,44 @@ export class NationModalTest extends TestCase {
|
||||
this.assertSize(env.$("#budget-list table.budget-table"), 1);
|
||||
|
||||
const text = env.$("#budget-list").text();
|
||||
this.assertTrue(text.includes("+€1 035/h"), "tax income is green");
|
||||
this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
|
||||
this.assertTrue(text.includes("Republic (government) +10.0%"), "a government bonus is listed");
|
||||
this.assertTrue(text.includes("−€400/h"), "the building group shows its total cost");
|
||||
this.assertTrue(text.includes("Modern infantry ×3"), "unit upkeep is listed");
|
||||
this.assertTrue(text.includes("+¤1 035"), "the month's tax income is shown");
|
||||
this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
|
||||
this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
|
||||
this.assertTrue(text.includes("−¤400"), "the building cash is listed");
|
||||
this.assertTrue(text.includes("materials valued at ¤700"), "the market value is noted");
|
||||
this.assertTrue(text.includes("Unit upkeep"), "a unit category is listed");
|
||||
this.assertTrue(text.includes("Bought Steel"), "a bought resource is listed");
|
||||
this.assertTrue(text.includes("Sold Food"), "a sold resource is listed");
|
||||
this.assertTrue(text.includes("TOTAL"), "a total row is present");
|
||||
this.assertTrue(text.includes("+€485/h"), "the total carries the net balance");
|
||||
this.assertTrue(text.includes("+¤545"), "the total carries the month net");
|
||||
this.assertTrue(env.$("#budget-sub").text().includes("Treasury"));
|
||||
this.assertTrue(env.$("#budget-sub").text().includes("January 2000"), "the month is named");
|
||||
|
||||
// Expanding the building type reveals the per-city cost.
|
||||
env.$("#budget-list tr.budget-group .budget-name").click();
|
||||
// Expanding an upkeep row names the resources it bought.
|
||||
env.$("#budget-list tr .budget-name.clickable").first().click();
|
||||
const expanded = env.$("#budget-list").text();
|
||||
this.assertTrue(expanded.includes("Steel"), "the expanded row names steel");
|
||||
this.assertTrue(expanded.includes("−¤250"), "and what it paid for it");
|
||||
|
||||
// The pager steps back to the previous month's digest.
|
||||
env.$("#budget-months .budget-month-nav").first().click();
|
||||
this.assertTrue(
|
||||
env.$("#budget-list").text().includes("Bank in Paris — level 2"),
|
||||
"expanding a building type shows its cities"
|
||||
env.$("#budget-sub").text().includes("December 1999"),
|
||||
"the pager names the older month"
|
||||
);
|
||||
|
||||
modal.refresh(0, new Set(), 1.0e9, 1.0e15, 1.0e6, {
|
||||
income: { rate: 0.001, base: 100, modifier: 0, total: 100, sources: [] },
|
||||
buildingUpkeep: { total: 400, sources: [], groups: [] },
|
||||
unitUpkeep: { total: 150, sources: [], groups: [] },
|
||||
net: -450,
|
||||
months: [month({
|
||||
income: 100,
|
||||
totalCash: 400,
|
||||
totalEconomic: 400,
|
||||
net: -300,
|
||||
expenses: [["buildings", 400, 400]],
|
||||
})],
|
||||
cities: [],
|
||||
});
|
||||
const refreshed = env.$("#budget-list").text();
|
||||
this.assertTrue(refreshed.includes("−€450/h"), "the total updates");
|
||||
this.assertTrue(refreshed.includes("No bonuses"), "empty income sources are named");
|
||||
this.assertTrue(refreshed.includes("−¤300"), "the total updates");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
@@ -522,8 +667,8 @@ export class NationModalTest extends TestCase {
|
||||
this.assertTrue(rows.eq(0).find(".name").text() === "Britain", "alphabetical by default");
|
||||
this.assertTrue(rows.eq(0).find(".status").text().includes("At peace"));
|
||||
const numbers = rows.eq(0).find("td.numeric");
|
||||
this.assertTrue(numbers.eq(0).text().includes("€3B"), "Britain's GDP shows");
|
||||
this.assertTrue(numbers.eq(1).text().includes("€2K"), "Britain's GDP/capita shows");
|
||||
this.assertTrue(numbers.eq(0).text().includes("¤3B"), "Britain's GDP shows");
|
||||
this.assertTrue(numbers.eq(1).text().includes("¤2K"), "Britain's GDP/capita shows");
|
||||
this.assertTrue(numbers.eq(2).text().includes("1.5M"), "Britain's population shows");
|
||||
|
||||
const own = rows.eq(1);
|
||||
@@ -586,6 +731,54 @@ export class NationModalTest extends TestCase {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_economy_tab_shows_private_cash_central_bank_and_rates() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
const money = {
|
||||
viewerCiv: 0,
|
||||
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
currencies: [
|
||||
{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 },
|
||||
{ civ: 1, code: "GBP", symbol: "£", name: "British Pound", value: 1 },
|
||||
],
|
||||
exchangeRates: [
|
||||
{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 },
|
||||
{ civ: 1, code: "GBP", symbol: "£", name: "British Pound", rate: 0.85 },
|
||||
],
|
||||
centralBank: {
|
||||
rate: 0.02,
|
||||
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
reserves: [{ code: "GBP", symbol: "£", name: "British Pound", amount: 1234 }],
|
||||
loans: 987654,
|
||||
},
|
||||
privateCash: 5000,
|
||||
cities: [{ id: 1, civ: 0, name: "Paris", cashValue: 5000 }],
|
||||
};
|
||||
modal.show(
|
||||
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
|
||||
{ months: [], cities: [] }, null, [], null, null, null, money
|
||||
);
|
||||
env.$("#modal-nation .tab[data-tab=economy]").click();
|
||||
this.assertTrue(env.$("#tab-economy").hasClass("active"));
|
||||
const summary = env.$("#economy-summary").text();
|
||||
this.assertTrue(summary.includes("Paris"), "the region is listed");
|
||||
this.assertTrue(summary.includes("5 000"), "its private cash is shown");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
|
||||
const bank = env.$("#economy-central-bank").text();
|
||||
this.assertTrue(bank.includes("British Pound"), "the foreign reserve is listed");
|
||||
this.assertTrue(bank.includes("987 654"), "the outstanding loans are listed");
|
||||
this.assertTrue(env.$("#cb-rate").length === 1, "the rate input is present");
|
||||
|
||||
env.$("#economy-subtabs .subtab[data-view=rates]").click();
|
||||
this.assertTrue(env.$("#economy-rates").text().includes("British Pound"), "the rate table shows");
|
||||
this.assertTrue(env.$("#economy-rates").text().includes("0.8500"), "the rate is shown");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfirmModalTest extends TestCase {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
import { MONEY } from "../shared/data/economy.js";
|
||||
|
||||
export class MoneyTest extends TestCase {
|
||||
// The money in the world: treasuries, region baskets and bank reserves, less
|
||||
// every debt owed to a bank. Rates are frozen by the caller so revaluation
|
||||
// does not move the measure.
|
||||
totalMoney(state) {
|
||||
let total = 0;
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
total += state.getBudget(civ);
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
total -= state.getRegionDebt(city);
|
||||
}
|
||||
for (const [, amount] of state.getCentralBankDebt(civ)) total -= amount;
|
||||
}
|
||||
for (const city of state.cities) {
|
||||
for (const [, amount] of state.getRegionCash(city)) total += amount;
|
||||
}
|
||||
for (const reserves of state.centralBankReserves.values()) {
|
||||
for (const [, amount] of reserves) total += amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
test_government_spending_is_paid_into_the_regions() {
|
||||
const state = smallState();
|
||||
const code = state.currencyOf(0).code;
|
||||
const cities = state.cities.filter((city) => city.civ === 0);
|
||||
const before = cities.map((city) => state.getRegionCash(city).get(code) || 0);
|
||||
const budgetBefore = state.getBudget(0);
|
||||
state._spendBudget(0, 1_000_000);
|
||||
const after = cities.map((city) => state.getRegionCash(city).get(code) || 0);
|
||||
const reached = after.reduce((sum, value, index) => sum + value - before[index], 0);
|
||||
this.assertApprox(reached, 1_000_000, 1, "the money reached the private sector");
|
||||
this.assertApprox(state.getBudget(0), budgetBefore - 1_000_000, 1e-6);
|
||||
}
|
||||
|
||||
test_taxes_are_taken_from_the_regions() {
|
||||
const state = smallState();
|
||||
const code = state.currencyOf(0).code;
|
||||
const cities = state.cities.filter((city) => city.civ === 0);
|
||||
const holding = () => cities.reduce(
|
||||
(sum, city) => sum + (state.getRegionCash(city).get(code) || 0), 0
|
||||
);
|
||||
const before = holding();
|
||||
state._chargeRegions(0, 500_000);
|
||||
this.assertApprox(before - holding(), 500_000, 1, "the tax came out of the regions");
|
||||
}
|
||||
|
||||
test_a_spend_neither_creates_nor_destroys_money() {
|
||||
const state = smallState();
|
||||
// Hold the exchange rates still so the measure reads units, not revaluation.
|
||||
state.setCurrencyValue = () => {};
|
||||
const before = this.totalMoney(state);
|
||||
state._spendBudget(0, 5_000_000, "training");
|
||||
this.assertApprox(this.totalMoney(state), before, 1, "the spend moved money, it did not mint it");
|
||||
}
|
||||
|
||||
test_a_market_purchase_neither_creates_nor_destroys_money() {
|
||||
const state = smallState();
|
||||
state.setCurrencyValue = () => {};
|
||||
const city = cityOf(state, 0);
|
||||
const before = this.totalMoney(state);
|
||||
state._chargeRegionCash(city, state.currencyOf(0).code, 2_000_000);
|
||||
state._payWorld(0, 2_000_000, state.currencyOf(0).code);
|
||||
this.assertApprox(this.totalMoney(state), before, 1, "the import stayed in the world");
|
||||
}
|
||||
|
||||
test_every_civilisation_has_a_currency() {
|
||||
const state = smallState();
|
||||
const codes = new Set();
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
const currency = state.currencyOf(civ);
|
||||
this.assertTrue(currency.name, "the currency is named");
|
||||
this.assertTrue(currency.code, "the currency has a code");
|
||||
this.assertTrue(currency.symbol, "the currency has a symbol");
|
||||
codes.add(currency.code);
|
||||
}
|
||||
this.assertEqual(codes.size, state.civilisations.length, "codes are unique");
|
||||
}
|
||||
|
||||
test_regions_open_with_private_cash_in_their_own_currency() {
|
||||
const state = smallState();
|
||||
for (const city of state.cities) {
|
||||
const basket = state.getRegionCash(city);
|
||||
this.assertSize(basket, 1, "a region opens holding one currency");
|
||||
const code = state.currencyOf(city.civ).code;
|
||||
this.assertTrue((basket.get(code) || 0) > 0, "its own national currency");
|
||||
this.assertGreater(state.getRegionCashValue(city), 0, "the reserve has value");
|
||||
}
|
||||
}
|
||||
|
||||
test_a_short_region_borrows_from_the_central_bank() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const code = state.currencyOf(0).code;
|
||||
state.getRegionCash(city).set(code, 0);
|
||||
state._chargeRegionCash(city, code, 500);
|
||||
this.assertApprox(state.getRegionDebt(city), 500, 1e-6, "the shortfall became debt");
|
||||
}
|
||||
|
||||
test_region_debt_accrues_interest_daily() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
// No cash to repay with, so the debt compounds.
|
||||
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
|
||||
state.regionDebt.set(city.id, 1000);
|
||||
state._tickMoney();
|
||||
this.assertApprox(
|
||||
state.getRegionDebt(city), 1000 * (1 + MONEY.defaultInterestRate / 365), 1e-6
|
||||
);
|
||||
}
|
||||
|
||||
test_setting_the_rate_changes_the_debt_interest() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
this.assertTrue(state.requestSetInterestRate(0, 0.1));
|
||||
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
|
||||
state.regionDebt.set(city.id, 1000);
|
||||
state._tickMoney();
|
||||
this.assertApprox(state.getRegionDebt(city), 1000 * (1 + 0.1 / 365), 1e-6);
|
||||
}
|
||||
|
||||
test_the_government_can_run_an_overdraft() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 100);
|
||||
this.assertTrue(state._spendBudget(0, 1000, "training"), "a spend is never refused");
|
||||
this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
|
||||
this.assertApprox(state.getGovernmentDebt(0), -state.getBudget(0), 1e-6);
|
||||
}
|
||||
|
||||
test_the_rate_is_clamped_and_rejects_nonsense() {
|
||||
const state = smallState();
|
||||
state.requestSetInterestRate(0, 5);
|
||||
this.assertEqual(state.getCentralBankRate(0), MONEY.maxInterestRate, "clamped to the ceiling");
|
||||
this.assertFalse(state.requestSetInterestRate(0, -1), "a negative rate is refused");
|
||||
this.assertFalse(state.requestSetInterestRate(99, 0.02), "an unknown nation is refused");
|
||||
}
|
||||
|
||||
test_the_private_sector_aggregates_its_regions() {
|
||||
const state = smallState();
|
||||
let sum = 0;
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
sum += state.getRegionCashValue(city);
|
||||
}
|
||||
this.assertApprox(state.getPrivateSectorCash(0), sum, 1e-6);
|
||||
}
|
||||
|
||||
test_exchange_rates_follow_the_currency_values() {
|
||||
const state = smallState();
|
||||
this.assertApprox(state.exchangeRate(0, 1), 1, 1e-9, "currencies start at par");
|
||||
state.setCurrencyValue(1, 0.5);
|
||||
this.assertApprox(state.exchangeRate(0, 1), 2, 1e-9, "a cheaper currency buys fewer");
|
||||
}
|
||||
|
||||
test_cross_border_trade_settles_through_the_central_bank() {
|
||||
const state = smallState();
|
||||
const buyer = cityOf(state, 0);
|
||||
const seller = cityOf(state, 1);
|
||||
state.getCityResourceStock(seller).food = 1e9;
|
||||
state.getCityResourceStock(buyer).food = 0;
|
||||
const buyerCode = state.currencyOf(0).code;
|
||||
const sellerCode = state.currencyOf(1).code;
|
||||
const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0;
|
||||
const sellerBefore = state.getRegionCash(seller).get(sellerCode) || 0;
|
||||
const bought = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
|
||||
);
|
||||
this.assertGreater(bought, 0, "the foreign neighbour sold food");
|
||||
this.assertLess(
|
||||
state.getRegionCash(buyer).get(buyerCode) || 0, buyerBefore, "the buyer spent its own currency"
|
||||
);
|
||||
this.assertGreater(
|
||||
state.getRegionCash(seller).get(sellerCode) || 0,
|
||||
sellerBefore,
|
||||
"the seller was paid in its own currency"
|
||||
);
|
||||
this.assertGreater(
|
||||
state.getCentralBankReserves(1).get(buyerCode) || 0,
|
||||
0,
|
||||
"the seller's central bank kept the buyer's currency as a reserve"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_currency_in_demand_strengthens() {
|
||||
const state = smallState();
|
||||
const before = state.currencyValue(0);
|
||||
state._recordCurrencyDemand(0, 1e12);
|
||||
state._tickMoney();
|
||||
this.assertGreater(state.currencyValue(0), before, "net demand lifts the rate");
|
||||
}
|
||||
|
||||
test_regions_repatriate_foreign_cash() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const national = state.currencyOf(0).code;
|
||||
const foreign = state.currencyOf(1).code;
|
||||
const basket = state.getRegionCash(city);
|
||||
basket.set(foreign, 1000);
|
||||
const nationalBefore = basket.get(national) || 0;
|
||||
state._tickMoney();
|
||||
this.assertLess(basket.get(foreign) || 0, 1000, "the foreign holding shrank");
|
||||
this.assertGreater(basket.get(national) || 0, nationalBefore, "national cash grew");
|
||||
}
|
||||
|
||||
test_a_broke_region_buys_by_borrowing() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
|
||||
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
|
||||
if (!coastal) return;
|
||||
coastal.buildings[portIndex] = 1;
|
||||
this.assertTrue(state._civMarketAccess(0), "a port opens the market");
|
||||
state.getRegionCash(city).set(state.currencyOf(0).code, 0);
|
||||
const store = state.getCityResourceStock(city);
|
||||
const bought = state._buyMarketMaterial(city, "food", 100, store);
|
||||
this.assertGreater(bought, 0, "the purchase still goes through");
|
||||
this.assertGreater(state.getRegionDebt(city), 0, "it was funded by borrowing");
|
||||
}
|
||||
|
||||
test_a_contested_surplus_is_bid_up() {
|
||||
const state = smallState();
|
||||
const buyer = cityOf(state, 0);
|
||||
const nodes = state._resourceNodes();
|
||||
const supplier = state._nearbySuppliers(buyer, "food", nodes)[0];
|
||||
this.assertNotNull(supplier, "a supplier is reachable");
|
||||
// Give that one node a huge surplus and drain every other store of food.
|
||||
for (const [, stock] of state.resourceStock) stock.food = 0;
|
||||
supplier.stock.food = 1e9;
|
||||
state.getCityResourceStock(buyer).food = 0;
|
||||
const receiverCity = supplier.city
|
||||
|| state._regionCityNear(supplier.civ, supplier.coords);
|
||||
const code = state.currencyOf(supplier.civ).code;
|
||||
const vSeller = state.currencyValue(supplier.civ);
|
||||
// Pretend another buyer already claimed most of the surplus.
|
||||
const surplus = state._nodeSurplus(supplier, "food");
|
||||
state._nodeClaims = new Map([
|
||||
[`${supplier.coords.x},${supplier.coords.y}`, surplus * 0.9],
|
||||
]);
|
||||
const before = state.getRegionCash(receiverCity).get(code) || 0;
|
||||
const store = state.getCityResourceStock(buyer);
|
||||
const bought = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, store, state._resourceNodes()
|
||||
);
|
||||
this.assertGreater(bought, 0, "food still changes hands");
|
||||
const paid = (state.getRegionCash(receiverCity).get(code) || 0) - before;
|
||||
const barePrice = (bought * state.getResourcePrice("food")) / vSeller;
|
||||
this.assertGreater(paid, barePrice, "the contested surplus fetched a premium");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_money_view() {
|
||||
const state = smallState();
|
||||
const stats = state.snapshot(0).viewerStats;
|
||||
this.assertTrue(stats.currency && stats.currency.code, "the viewer's currency travels");
|
||||
this.assertSize(stats.currencies, state.civilisations.length, "every currency travels");
|
||||
this.assertSize(stats.exchangeRates, state.civilisations.length, "every rate travels");
|
||||
this.assertTrue(stats.centralBank, "the central bank travels");
|
||||
this.assertApprox(stats.centralBank.rate, MONEY.defaultInterestRate, 1e-9);
|
||||
this.assertGreater(stats.privateCash, 0, "the private-sector total travels");
|
||||
const city = state.snapshot(0).cityStats[0];
|
||||
this.assertNotNull(city.cash, "a region's cash basket travels");
|
||||
this.assertGreater(city.cashValue, 0, "and its value");
|
||||
}
|
||||
}
|
||||
@@ -365,6 +365,14 @@ export class PoliticsModelTest extends TestCase {
|
||||
|
||||
test_cultural_impact_is_zero_sum_and_wins_friends() {
|
||||
const state = smallState();
|
||||
// Funded treasuries and private-sector cash, so the test measures culture
|
||||
// alone: a nation short of either runs a resource shortage, whose popularity
|
||||
// hit drowns out the museum.
|
||||
state.budgets.set(0, 1e15);
|
||||
state.budgets.set(1, 1e15);
|
||||
for (const city of state.cities) {
|
||||
state.getRegionCash(city).set(state.currencyOf(city.civ).code, 1e15);
|
||||
}
|
||||
const museum = BUILDINGS.findIndex((b) => b.id === "museum");
|
||||
const city = state.cities.find((c) => c.civ === 0);
|
||||
city.buildings[museum] = 1;
|
||||
|
||||
@@ -138,13 +138,13 @@ export class EconomyOrderEdgesTest extends TestCase {
|
||||
test_battle_triples_unit_upkeep() {
|
||||
const state = smallState();
|
||||
const pair = boundaryLand(state);
|
||||
const before = state.getPlayerUpkeep(0);
|
||||
const before = state.getCivUpkeepResources(0).steel;
|
||||
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
|
||||
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
|
||||
// The two hostile units are only "in battle" once they share a tile.
|
||||
state.units[state.units.length - 1].coords = pair.a;
|
||||
const after = state.getPlayerUpkeep(0);
|
||||
this.assertGreater(after, before, "a unit in battle costs more to keep");
|
||||
const after = state.getCivUpkeepResources(0).steel;
|
||||
this.assertGreater(after, before, "a unit in battle burns more steel");
|
||||
}
|
||||
|
||||
test_buildings_in_the_catalogue_have_a_cost_curve() {
|
||||
@@ -208,7 +208,7 @@ export class CatalogueSplitTest extends TestCase {
|
||||
test_pillaging_with_no_units_changes_nothing() {
|
||||
const result = pillageSteps(5000, 0);
|
||||
this.assertEmpty(result.steps);
|
||||
this.assertEqual(result.gdpMultiplier, 1);
|
||||
this.assertEqual(result.productionMultiplier, 1);
|
||||
}
|
||||
|
||||
test_effect_value_is_zero_for_a_non_positive_level() {
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf, grantBuilding } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import {
|
||||
RESOURCE_RULES,
|
||||
RESOURCE_MARKET_BASE,
|
||||
RESOURCE_BUILDINGS,
|
||||
EFFECT_RESEARCH,
|
||||
technologyCost,
|
||||
} from "../shared/data.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
import {
|
||||
formatEnergy,
|
||||
formatResourceAmount,
|
||||
foodNeedPerDay,
|
||||
steelNeedPerDay,
|
||||
luxuryNeedPerDay,
|
||||
foodMonthlyTarget,
|
||||
cityEnergyPerDay,
|
||||
deliveryEnergyKwh,
|
||||
deliveryEnergyForResource,
|
||||
nextMarketPrice,
|
||||
} from "../shared/resources.js";
|
||||
|
||||
function freeLandTile(state, civ = 0) {
|
||||
for (const coords of state._territoryByCiv.get(civ) || []) {
|
||||
if (state.cityAt(coords)) continue;
|
||||
if (state.tileImprovements.has(key(coords.x, coords.y))) continue;
|
||||
if (state._isLand(coords)) return coords;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function placeResourceBuilding(state, coords, id, civ = 0) {
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, id);
|
||||
state.tileImprovementOwner.set(k, civ);
|
||||
state.tileImprovementHp.set(k, 1000);
|
||||
state._ensureResourceStock(k);
|
||||
return k;
|
||||
}
|
||||
|
||||
export class ResourceRulesTest extends TestCase {
|
||||
test_energy_formats_in_friendly_units() {
|
||||
this.assertEqual(formatEnergy(500), "500 kWh");
|
||||
this.assertTrue(formatEnergy(1_500).includes("MWh"));
|
||||
this.assertTrue(formatEnergy(2_500_000).includes("GWh"));
|
||||
this.assertTrue(formatEnergy(3_000_000_000).includes("TWh"));
|
||||
}
|
||||
|
||||
test_material_units_step_up() {
|
||||
this.assertEqual(formatResourceAmount("steel", 250), "250 t");
|
||||
this.assertTrue(formatResourceAmount("steel", 2_500_000).includes("kt"));
|
||||
this.assertTrue(formatResourceAmount("steel", 2_500_000_000).includes("Mt"));
|
||||
this.assertTrue(formatResourceAmount("luxury", 2500).includes("ct"));
|
||||
}
|
||||
|
||||
test_per_person_needs_follow_the_rules() {
|
||||
// 1 kg a day of food and steel, 0.01 ct of luxury, for 2 million people.
|
||||
this.assertApprox(foodNeedPerDay(2_000_000), 2000);
|
||||
this.assertApprox(steelNeedPerDay(2_000_000), 2000);
|
||||
this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000);
|
||||
}
|
||||
|
||||
test_food_target_is_a_month_with_a_cushion() {
|
||||
const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer;
|
||||
this.assertApprox(foodMonthlyTarget(2_000_000), need);
|
||||
}
|
||||
|
||||
test_city_energy_scales_with_gdp() {
|
||||
this.assertApprox(cityEnergyPerDay(0), 0);
|
||||
this.assertGreater(cityEnergyPerDay(1e11), 0);
|
||||
}
|
||||
|
||||
test_delivery_energy_matches_the_roadmap_figures() {
|
||||
// 1 tonne, 1 tile: 10 MWh by road, 1 MWh by train, 50 kWh by ship.
|
||||
this.assertApprox(deliveryEnergyKwh(1, 1, "road"), 10_000);
|
||||
this.assertApprox(deliveryEnergyKwh(1, 1, "train"), 1_000);
|
||||
this.assertApprox(deliveryEnergyKwh(1, 1, "ship"), 50);
|
||||
// Off-road trucks burn 50 MWh a tile, times the tile's movement cost.
|
||||
this.assertApprox(deliveryEnergyKwh(1, 1, "truck", 4), 200_000);
|
||||
}
|
||||
|
||||
test_energy_delivery_counts_ten_gwh_per_tonne() {
|
||||
// Delivering 10 GWh of energy is one tonne, so its transport is the same
|
||||
// as moving one tonne of steel the same distance.
|
||||
const asEnergy = deliveryEnergyForResource("energy", 10_000_000, 1, "road");
|
||||
const asSteel = deliveryEnergyForResource("steel", 1, 1, "road");
|
||||
this.assertApprox(asEnergy, asSteel);
|
||||
}
|
||||
|
||||
test_market_price_rises_on_shortage_and_falls_on_glut() {
|
||||
const base = RESOURCE_MARKET_BASE.steel;
|
||||
const scarce = nextMarketPrice(base, base, 10, 20);
|
||||
const glut = nextMarketPrice(base, base, 20, 10);
|
||||
this.assertGreater(scarce, base);
|
||||
this.assertLess(glut, base);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceModelTest extends TestCase {
|
||||
test_cities_start_with_a_food_store() {
|
||||
const state = smallState();
|
||||
for (const city of state.cities) {
|
||||
const stock = state.getCityResourceStock(city);
|
||||
this.assertGreater(stock.food, 0, "the city opens with food");
|
||||
this.assertGreater(stock.steel, 0, "the city opens with steel");
|
||||
}
|
||||
}
|
||||
|
||||
test_construction_resource_cost_follows_the_money_cost() {
|
||||
const state = smallState();
|
||||
const cost = state.constructionResourceCost({ buildCost: 1_000_000, effects: [] });
|
||||
this.assertApprox(cost.steel, 1_000_000 * RESOURCE_RULES.steelPerBudgetEuro);
|
||||
this.assertEqual(cost.hightech, 0);
|
||||
const lab = state.protoBuildings.find((b) => b.id === "research_lab");
|
||||
this.assertGreater(state.constructionResourceCost(lab, 0, lab.baseCost).hightech, 0);
|
||||
}
|
||||
|
||||
test_resource_money_tracks_buying_and_selling() {
|
||||
const state = smallState();
|
||||
state._recordResourceSpend(0, "goods", 1000, "steel");
|
||||
state._recordResourceEarn(0, "steel", 400);
|
||||
const breakdown = state.budgetBreakdown(0);
|
||||
const steel = breakdown.resourceMoney.find((entry) => entry.id === "steel");
|
||||
this.assertNotNull(steel, "steel appears in the trade breakdown");
|
||||
this.assertApprox(steel.bought, 1000);
|
||||
this.assertApprox(steel.sold, 400);
|
||||
}
|
||||
|
||||
test_national_gdp_per_capita_matches_the_regions() {
|
||||
const state = smallState();
|
||||
const snapshot = state.snapshot(0);
|
||||
let gdp = 0;
|
||||
let population = 0;
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
|
||||
gdp += stats.gdp;
|
||||
population += stats.population;
|
||||
}
|
||||
this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
|
||||
}
|
||||
|
||||
test_construction_draws_steel_from_the_city_before_the_market() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1000;
|
||||
const before = state.getBudget(0);
|
||||
const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 });
|
||||
this.assertTrue(ok);
|
||||
this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store");
|
||||
this.assertApprox(state.getBudget(0), before, 1e-6, "no market bill was needed");
|
||||
}
|
||||
|
||||
test_construction_buys_the_shortfall_on_the_market() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
// Every city of the nation must be empty, or the draw would find another
|
||||
// store before it reaches the market.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
|
||||
}
|
||||
const price = state.getResourcePrice("steel");
|
||||
const before = state.getBudget(0);
|
||||
const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 });
|
||||
this.assertTrue(ok);
|
||||
this.assertApprox(before - state.getBudget(0), 1000 * price, 1e-3);
|
||||
}
|
||||
|
||||
test_research_spends_high_tech() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
grantBuilding(state, city, "research_lab");
|
||||
const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
|
||||
this.assertTrue(state.requestFocusTechnology(0, tech));
|
||||
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
|
||||
// With a store of high-tech the lab's one point an hour comes through.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).hightech = 1000;
|
||||
}
|
||||
const before = state.getTechnologyProgress(0, tech);
|
||||
state.advanceHour();
|
||||
const withTech = state.getTechnologyProgress(0, tech) - before;
|
||||
this.assertApprox(withTech, governmentRate + 1.0, 1e-6);
|
||||
// With no store, the lab's point is simply not produced.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
|
||||
}
|
||||
const before2 = state.getTechnologyProgress(0, tech);
|
||||
state.advanceHour();
|
||||
const withoutTech = state.getTechnologyProgress(0, tech) - before2;
|
||||
this.assertApprox(withoutTech, governmentRate, 1e-6);
|
||||
}
|
||||
|
||||
test_a_power_plant_supplies_its_grid() {
|
||||
const withPlant = smallState();
|
||||
const tile = freeLandTile(withPlant, 0);
|
||||
placeResourceBuilding(withPlant, tile, "coal_power_plant", 0);
|
||||
withPlant._tickResources();
|
||||
const stats = withPlant.resourceMarketStats.get("energy");
|
||||
this.assertGreater(stats.supply, 0, "the plant fed the grid");
|
||||
}
|
||||
|
||||
test_gdp_is_the_market_value_of_what_a_region_produces() {
|
||||
const state = smallState();
|
||||
const tile = freeLandTile(state, 0);
|
||||
const region = state.regionCityAt(tile);
|
||||
this.assertNotNull(region, "the tile belongs to a region");
|
||||
const before = state._regionProductionValue(region);
|
||||
placeResourceBuilding(state, tile, "steel_mill", 0);
|
||||
const after = state._regionProductionValue(region);
|
||||
const proto = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
|
||||
const outputValue = proto.outputPerDay * state.getResourcePrice("steel") * DAYS_PER_YEAR;
|
||||
this.assertApprox(after - before, outputValue, outputValue * 1e-6, "the mill's output is GDP");
|
||||
}
|
||||
|
||||
test_a_steel_mill_consumes_energy_and_makes_steel() {
|
||||
const state = smallState();
|
||||
const plantTile = freeLandTile(state, 0);
|
||||
placeResourceBuilding(state, plantTile, "coal_power_plant", 0);
|
||||
// A second owned tile for the mill, away from the city.
|
||||
const millTile = state._territoryByCiv.get(0).find(
|
||||
(coords) => state._isLand(coords) && !state.cityAt(coords) &&
|
||||
key(coords.x, coords.y) !== key(plantTile.x, plantTile.y)
|
||||
);
|
||||
const k = placeResourceBuilding(state, millTile, "steel_mill", 0);
|
||||
state._tickResources();
|
||||
const stock = state._ensureResourceStock(k);
|
||||
this.assertGreater(stock.steel, 0, "the mill produced steel");
|
||||
}
|
||||
|
||||
test_a_city_buys_from_a_neighbour_before_the_market() {
|
||||
const state = smallState();
|
||||
const cities = state.cities.filter((c) => c.civ === 0);
|
||||
this.assertGreaterOrEqual(cities.length, 2, "the fixture has two cities");
|
||||
const buyer = cities[0];
|
||||
const seller = cities[1];
|
||||
state.getCityResourceStock(seller).food = 1e9;
|
||||
state.getCityResourceStock(buyer).food = 0;
|
||||
const store = state.getCityResourceStock(buyer);
|
||||
const bought = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, store, state._resourceNodes()
|
||||
);
|
||||
this.assertGreater(bought, 0, "the neighbour sold food");
|
||||
this.assertGreater(store.food, 0, "the buyer received it");
|
||||
this.assertTrue(
|
||||
state.resourceLinks.some((link) => link.resource === "food"),
|
||||
"the delivery is on the graph"
|
||||
);
|
||||
}
|
||||
|
||||
test_foreign_trade_runs_on_the_transport_network() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const buyer = cityOf(state, 0);
|
||||
const seller = cityOf(state, 1);
|
||||
state.getCityResourceStock(seller).food = 1e9;
|
||||
state.getCityResourceStock(buyer).food = 0;
|
||||
const store = state.getCityResourceStock(buyer);
|
||||
const boughtOnNetwork = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, store, state._resourceNodes()
|
||||
);
|
||||
this.assertGreater(boughtOnNetwork, 0, "a foreign market on the network is reachable");
|
||||
|
||||
// Tear up every road and railway: the foreign market is now unreachable.
|
||||
state.roads.clear();
|
||||
state.railways.clear();
|
||||
store.food = 0;
|
||||
state.resourceLinks = [];
|
||||
const boughtOffNetwork = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, store, state._resourceNodes()
|
||||
);
|
||||
this.assertEqual(boughtOffNetwork, 0, "no road, no foreign market");
|
||||
}
|
||||
|
||||
test_cross_nation_deliveries_are_booked_as_imports_and_exports() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const buyer = cityOf(state, 0);
|
||||
const seller = cityOf(state, 1);
|
||||
state.getCityResourceStock(seller).food = 1e9;
|
||||
state.getCityResourceStock(buyer).food = 0;
|
||||
const bought = state._procureFromNeighbours(
|
||||
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
|
||||
);
|
||||
this.assertGreater(bought, 0, "the neighbour sold food");
|
||||
this.assertApprox(state._civResourceTrade(0).imports.food, bought, 1e-6, "the buyer imported it");
|
||||
this.assertApprox(state._civResourceTrade(1).exports.food, bought, 1e-6, "the seller exported it");
|
||||
// The snapshot carries every nation's trade ledger, so the panel can rank
|
||||
// the world's major exporters and importers.
|
||||
const snapshot = state.snapshot(0);
|
||||
this.assertSize(snapshot.resourceTrade, 2);
|
||||
this.assertApprox(snapshot.resourceTrade[1].exports.food, bought, 1e-3);
|
||||
}
|
||||
|
||||
test_a_shortage_costs_popularity() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const before = state.getCivPopularity(0);
|
||||
state._resourceShortages.set(city.id, { steel: 1 });
|
||||
state._applyResourceShortages(new Map());
|
||||
this.assertLess(state.getCivPopularity(0), before, "a shortage hurt the government");
|
||||
}
|
||||
|
||||
test_a_resource_building_cannot_stand_inside_a_city() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
this.assertFalse(
|
||||
state.requestBuildTileImprovement(0, city.coords, "coal_power_plant"),
|
||||
"a power plant belongs on open land"
|
||||
);
|
||||
const tile = freeLandTile(state, 0);
|
||||
state.budgets.set(0, 1e15);
|
||||
this.assertTrue(
|
||||
state.requestBuildTileImprovement(0, tile, "coal_power_plant"),
|
||||
"and is accepted on a controlled land tile"
|
||||
);
|
||||
}
|
||||
|
||||
test_snapshot_ships_the_market_and_the_delivery_graph() {
|
||||
const state = smallState();
|
||||
const snapshot = state.snapshot(0);
|
||||
this.assertSize(snapshot.resourceMarket, 5);
|
||||
for (const entry of snapshot.resourceMarket) {
|
||||
this.assertHas(entry, "price");
|
||||
this.assertGreater(entry.price, 0);
|
||||
}
|
||||
this.assertHas(snapshot.viewerStats, "resources");
|
||||
this.assertHas(snapshot.resourceGraph, "nodes");
|
||||
this.assertHas(snapshot.resourceGraph, "links");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- starting industry ----
|
||||
|
||||
test_the_opening_economy_is_seeded_with_covering_industry() {
|
||||
const state = smallState();
|
||||
this.assertGreater(state.tileImprovements.size, 0, "the world opens with resource works");
|
||||
const components = state._resourceLandComponents();
|
||||
const foodEnergy = state._foodEnergyByComponent(components);
|
||||
for (let civ = 0; civ < state.civilisations.length; civ++) {
|
||||
const deficits = state._resourceSeedDeficits(civ);
|
||||
this.assertTrue(deficits.steel <= 0, "steel production covers the people and the works");
|
||||
this.assertTrue(deficits.luxury <= 0, "luxury production covers the people");
|
||||
const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
|
||||
this.assertEmpty(energy, "every grid has the power its cities and industry draw");
|
||||
}
|
||||
}
|
||||
|
||||
test_seeded_industry_is_connected_to_the_transport_network() {
|
||||
const state = smallState();
|
||||
this.assertGreater(state.tileImprovements.size, 0, "there are seeded works");
|
||||
for (const k of state.tileImprovements.keys()) {
|
||||
this.assertTrue(
|
||||
state.roads.has(k) || state.railways.has(k),
|
||||
"a seeded work sits on the transport network"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test_seeding_is_deterministic_from_the_world_seed() {
|
||||
const first = smallState(["france", "britain"], 12345);
|
||||
const again = smallState(["france", "britain"], 12345);
|
||||
const other = smallState(["france", "britain"], 999);
|
||||
const describe = (state) =>
|
||||
Array.from(state.tileImprovements.entries())
|
||||
.map(([k, id]) => `${k}:${id}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
this.assertEqual(describe(first), describe(again), "the same seed lays out the same works");
|
||||
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- upkeep -------
|
||||
|
||||
test_building_upkeep_is_materials_per_level_per_day() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const before = state.getBuildingUpkeep(0);
|
||||
const index = grantBuilding(state, city, "research_lab", 1);
|
||||
const after = state.getBuildingUpkeep(0);
|
||||
// A 100 M lab: 50 t of steel, 100 MWh of energy and 200 high-tech a day.
|
||||
this.assertApprox(after.steel - before.steel, 50, 1e-6);
|
||||
this.assertApprox(after.hightech - before.hightech, 200, 1e-6);
|
||||
const beforeLevel = after.steel;
|
||||
city.buildings[index] = 2;
|
||||
this.assertGreater(state.getBuildingUpkeep(0).steel, beforeLevel, "a higher level wears more");
|
||||
}
|
||||
|
||||
test_the_state_pays_upkeep_materials_from_the_budget() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
grantBuilding(state, city, "market", 4);
|
||||
// Drain the stores so the day's upkeep has to be bought.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
|
||||
}
|
||||
state.advanceHour();
|
||||
// The material bill is settled on the daily tick, not hourly.
|
||||
for (let i = 0; i < 23; i++) state.advanceHour();
|
||||
const spend = state.resourceSpend.get(0) || {};
|
||||
this.assertGreater(spend.upkeep || 0, 0, "the spend is booked as upkeep");
|
||||
// The materials bill lands in the month's budget under an upkeep category.
|
||||
const report = state.budgetMonthReports(0).at(-1);
|
||||
const upkeep = report.expenses.filter(
|
||||
(entry) => ["buildings", "units", "works", "transport"].includes(entry.id)
|
||||
);
|
||||
this.assertGreater(upkeep.length, 0, "the treasury paid it, filed as upkeep");
|
||||
}
|
||||
|
||||
test_unaffordable_upkeep_costs_popularity() {
|
||||
const state = smallState();
|
||||
const before = state.getCivPopularity(0);
|
||||
state._civilianUpkeepShortage(0, 1);
|
||||
this.assertLess(state.getCivPopularity(0), before, "an unpaid bill is unrest");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- repairs ------
|
||||
|
||||
test_a_razed_building_level_is_rebuilt_with_materials() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const index = grantBuilding(state, city, "market", 1);
|
||||
city.buildings[index] = 0;
|
||||
state._queueCityRepair(city, index, 1);
|
||||
state._tickRepairs();
|
||||
this.assertEqual(city.buildings[index], 1, "the level came back once its materials were paid");
|
||||
}
|
||||
|
||||
test_a_damaged_tile_work_heals_once_its_debt_is_paid() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const tile = freeLandTile(state, 0);
|
||||
const k = placeResourceBuilding(state, tile, "fortifications", 0);
|
||||
state.tileImprovementHp.set(k, 200);
|
||||
state._recordRepairDebt(tile, 200);
|
||||
const debt = state.tileRepairDebt.get(k);
|
||||
this.assertGreater(debt, 0, "damage left a debt");
|
||||
for (let i = 0; i < 20; i++) state._tickRepairs();
|
||||
this.assertFalse(state.tileRepairDebt.has(k), "the debt was paid off");
|
||||
this.assertGreater(state.tileImprovementHp.get(k), 200, "the work healed");
|
||||
}
|
||||
|
||||
test_a_damaged_unit_heals_at_a_materials_cost() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
|
||||
unit.hp = 100;
|
||||
// Empty the stores so the heal must be bought.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
|
||||
}
|
||||
const before = state.getBudget(0);
|
||||
state._tickGarrisonHealing();
|
||||
this.assertGreater(unit.hp, 100, "the unit healed");
|
||||
this.assertLess(state.getBudget(0), before, "healing cost the treasury");
|
||||
}
|
||||
|
||||
test_a_tile_with_repair_debt_does_not_recover_its_gdp() {
|
||||
const state = smallState();
|
||||
const tile = freeLandTile(state, 0);
|
||||
const k = key(tile.x, tile.y);
|
||||
state.tileBattleProductionDeficit.set(k, 1000);
|
||||
state.tileBattleRecoveryStart.set(k, 0);
|
||||
state._recordRepairDebt(tile, 100);
|
||||
state._tickProductionRecovery();
|
||||
this.assertApprox(state.tileBattleProductionDeficit.get(k), 1000, 1e-6, "the scar waits for its materials");
|
||||
}
|
||||
|
||||
// ------------------------------------------------ tech gate / combat --
|
||||
|
||||
test_fusion_needs_its_technology() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const tile = freeLandTile(state, 0);
|
||||
this.assertFalse(
|
||||
state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
|
||||
"fusion is locked without the technology"
|
||||
);
|
||||
const index = state.technologies.findIndex((t) => t.id === "fusion_power");
|
||||
this.assertGreater(index, 0, "the fusion technology exists");
|
||||
this.assertEqual(technologyCost(state.technologies[index]), 500_000, "it costs half a million points");
|
||||
state.researched.get(0).add(index);
|
||||
state.budgets.set(0, 1e15);
|
||||
this.assertTrue(
|
||||
state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
|
||||
"and unlocks the plant once researched"
|
||||
);
|
||||
}
|
||||
|
||||
test_infantry_food_multiplies_in_combat_and_when_resting() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const unit = state._spawnUnit(city.coords, 0, proto);
|
||||
const base = foodNeedPerDay(proto.population);
|
||||
this.assertApprox(state._unitFoodRate(unit, proto, false), base, 1e-9, "idle rations");
|
||||
this.assertApprox(
|
||||
state._unitFoodRate(unit, proto, true),
|
||||
base * RESOURCE_RULES.infantryCombatFoodMultiplier,
|
||||
1e-9,
|
||||
"combat rations"
|
||||
);
|
||||
unit.hp = 100;
|
||||
this.assertApprox(
|
||||
state._unitFoodRate(unit, proto, false),
|
||||
base * RESOURCE_RULES.infantryRestingFoodMultiplier,
|
||||
1e-9,
|
||||
"wounded and resting rations"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_land_unit_carries_fifty_days_of_food() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const unit = state._spawnUnit(city.coords, 0, proto);
|
||||
this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "a full pack");
|
||||
}
|
||||
|
||||
test_a_supplied_land_unit_tops_up_its_pack() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const unit = state._spawnUnit(city.coords, 0, proto);
|
||||
unit.foodDays = 10;
|
||||
state._tickUnitSupply();
|
||||
this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "it is fed at home");
|
||||
}
|
||||
|
||||
test_a_cut_off_land_unit_starves() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const unit = state._spawnUnit(city.coords, 0, proto);
|
||||
// No reachable allied city, and an empty pack: the soldiers start dying.
|
||||
state._unitSupplyCity = () => null;
|
||||
unit.foodDays = 0;
|
||||
const before = unit.hp;
|
||||
state._tickUnitSupply();
|
||||
this.assertLess(unit.hp, before, "starvation costs hit points");
|
||||
}
|
||||
|
||||
test_air_and_naval_units_do_not_carry_supply() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
|
||||
const unit = state._spawnUnit(city.coords, 0, jet, city.id);
|
||||
unit.foodDays = 5;
|
||||
state._unitSupplyCity = () => null;
|
||||
state._tickUnitSupply();
|
||||
this.assertEqual(unit.foodDays, 5, "an aircraft uses endurance, not a food pack");
|
||||
}
|
||||
|
||||
test_an_encircled_city_cannot_buy_from_the_market() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
// Ring the city with enemies and empty its stores.
|
||||
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
|
||||
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
|
||||
}
|
||||
const store = state.getCityResourceStock(city);
|
||||
store.food = 0;
|
||||
store.steel = 0;
|
||||
store.luxury = 0;
|
||||
const market = new Map(
|
||||
["food", "steel", "luxury", "energy", "hightech"].map((id) => [id, { demand: 0 }])
|
||||
);
|
||||
state._consumeCityResources(new Map(), market);
|
||||
this.assertEqual(store.food, 0, "no food came in through the ring");
|
||||
this.assertGreater(state._resourceShortages.size, 0, "the shortage is recorded");
|
||||
}
|
||||
|
||||
test_an_encircled_unit_cannot_resupply() {
|
||||
const state = smallState();
|
||||
const unit = state.units[0];
|
||||
for (const neighbour of state.topology.neighbours(unit.coords.x, unit.coords.y)) {
|
||||
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
|
||||
}
|
||||
unit.foodDays = 10;
|
||||
state._tickUnitSupply();
|
||||
this.assertLess(unit.foodDays, 10, "a cut-off unit eats its reserves");
|
||||
}
|
||||
|
||||
test_a_unit_off_the_network_starves() {
|
||||
const state = smallState();
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const tile = freeLandTile(state, 0);
|
||||
const unit = state._spawnUnit(tile, 0, proto);
|
||||
// Tear up the network: the unit is cut off and eats its reserves.
|
||||
state.roads.clear();
|
||||
state.railways.clear();
|
||||
unit.foodDays = 10;
|
||||
state._tickUnitSupply();
|
||||
this.assertLess(unit.foodDays, 10, "no road, no resupply");
|
||||
}
|
||||
|
||||
test_naval_cities_start_with_a_port() {
|
||||
const state = smallState();
|
||||
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
|
||||
const inland = state.cities.find((c) => c.civ === 0 && !state.isCoastalCity(c));
|
||||
this.assertNotNull(coastal, "the nation has a naval city");
|
||||
this.assertTrue(state.hasCityBuilding(coastal, "port"), "a naval city starts with a port");
|
||||
this.assertFalse(inland && state.hasCityBuilding(inland, "port"), "an inland city does not");
|
||||
}
|
||||
|
||||
test_a_city_without_a_port_has_no_foreign_market() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const store = state.getCityResourceStock(city);
|
||||
const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) delete own.buildings[portIndex];
|
||||
}
|
||||
this.assertFalse(state._civMarketAccess(0), "no working port, no market");
|
||||
this.assertEqual(state._buyMarketMaterial(city, "food", 100, store), 0, "nothing can be bought");
|
||||
|
||||
const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
|
||||
coastal.buildings[portIndex] = 1;
|
||||
this.assertTrue(state._civMarketAccess(0), "the port opens the market");
|
||||
this.assertGreater(state._buyMarketMaterial(city, "food", 100, store), 0, "goods now arrive");
|
||||
}
|
||||
|
||||
test_air_defense_spends_ammunition_when_it_fires() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
grantBuilding(state, city, "aa_building");
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) {
|
||||
const store = state.getCityResourceStock(own);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
}
|
||||
const plane = state.protoUnits.find((p) => p.id === "jet_fighter");
|
||||
const intruder = state._spawnUnit(city.coords, 1, plane);
|
||||
state._airDefenseVolley(intruder);
|
||||
const spend = state.resourceSpend.get(0) || {};
|
||||
this.assertGreater(spend.combat || 0, 0, "firing the air defense cost materials");
|
||||
}
|
||||
|
||||
test_training_a_unit_draws_its_soldiers_from_the_region() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const before = state.regionPopulation(city);
|
||||
state._spawnTrainedUnit(city, proto);
|
||||
const after = state.regionPopulation(city);
|
||||
this.assertApprox(before - after, proto.population, 1e-6, "1000 people became soldiers");
|
||||
}
|
||||
|
||||
test_healing_a_unit_draws_replacement_soldiers() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
|
||||
const unit = state._spawnUnit(city.coords, 0, proto);
|
||||
unit.hp = 100;
|
||||
const before = state.regionPopulation(city);
|
||||
state._tickGarrisonHealing();
|
||||
const gained = unit.hp - 100;
|
||||
this.assertGreater(gained, 0, "the unit healed");
|
||||
// Each hit point is a share of the unit's soldiers, drawn from the people.
|
||||
this.assertApprox(
|
||||
before - state.regionPopulation(city),
|
||||
(gained / unit.maxHp) * proto.population,
|
||||
1e-6
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,15 @@ import {
|
||||
defenseFactors,
|
||||
defenseMultiplier,
|
||||
unitDefense,
|
||||
gdpPerCapitaFactors,
|
||||
gdpPerCapitaFromFactors,
|
||||
productionFactors,
|
||||
productionFromFactors,
|
||||
} from "../shared/rules.js";
|
||||
import {
|
||||
effectValue,
|
||||
GOVERNMENTS,
|
||||
TECHNOLOGIES,
|
||||
TERRAIN_TILES,
|
||||
EFFECT_GDP,
|
||||
EFFECT_PRODUCTION,
|
||||
EFFECT_RESEARCH,
|
||||
EFFECT_CULTURE,
|
||||
} from "../shared/data.js";
|
||||
@@ -75,15 +75,15 @@ export class RulesTest extends TestCase {
|
||||
test_building_effect_value_sums_matching_stats() {
|
||||
const building = proto({
|
||||
effects: [
|
||||
{ stat: "gdp", baseValue: 0.04, exponent: 0.75 },
|
||||
{ stat: "gdp", baseValue: 0.01, exponent: 1.0 },
|
||||
{ stat: "production", baseValue: 0.04, exponent: 0.75 },
|
||||
{ stat: "production", baseValue: 0.01, exponent: 1.0 },
|
||||
{ stat: "culture", baseValue: 1.0, exponent: 1.0 },
|
||||
],
|
||||
});
|
||||
this.assertApprox(buildingEffectValue(building, "gdp", 1), 0.05);
|
||||
this.assertApprox(buildingEffectValue(building, "production", 1), 0.05);
|
||||
this.assertApprox(buildingEffectValue(building, "culture", 1), 1.0);
|
||||
this.assertEqual(buildingEffectValue(building, "research", 1), 0);
|
||||
this.assertEqual(buildingEffectValue(building, "gdp", 0), 0);
|
||||
this.assertEqual(buildingEffectValue(building, "production", 0), 0);
|
||||
}
|
||||
|
||||
test_government_change_cost() {
|
||||
@@ -95,7 +95,7 @@ export class RulesTest extends TestCase {
|
||||
|
||||
test_catalogue_effects() {
|
||||
const monarchy = GOVERNMENTS.find((g) => g.id === "monarchy");
|
||||
this.assertApprox(governmentEffects(monarchy)[EFFECT_GDP], 0.1);
|
||||
this.assertApprox(governmentEffects(monarchy)[EFFECT_PRODUCTION], 0.1);
|
||||
this.assertApprox(governmentEffects(monarchy)[EFFECT_CULTURE], 1.0);
|
||||
const technocracy = GOVERNMENTS.find((g) => g.id === "technocracy");
|
||||
this.assertGreater(
|
||||
@@ -104,7 +104,7 @@ export class RulesTest extends TestCase {
|
||||
);
|
||||
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
|
||||
this.assertApprox(technologyEffects(method)[EFFECT_RESEARCH], 1.0);
|
||||
this.assertApprox(technologyEffects(method)[EFFECT_GDP], 0.02);
|
||||
this.assertApprox(technologyEffects(method)[EFFECT_PRODUCTION], 0.02);
|
||||
}
|
||||
|
||||
test_prerequisites_met() {
|
||||
@@ -174,40 +174,40 @@ export class RulesTest extends TestCase {
|
||||
this.assertApprox(bonusOf("Land"), 0);
|
||||
}
|
||||
|
||||
test_gdp_per_capita_factors_name_the_terrain_and_settlement() {
|
||||
test_production_factors_name_the_terrain_and_settlement() {
|
||||
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
|
||||
const base = gdpPerCapitaFactors(plains);
|
||||
const base = productionFactors(plains);
|
||||
this.assertSize(base, 1);
|
||||
this.assertEqual(base[0].label, "Land");
|
||||
this.assertApprox(base[0].multiplier, 1);
|
||||
|
||||
const city = gdpPerCapitaFactors(plains, { cityKind: "city" });
|
||||
const city = productionFactors(plains, { cityKind: "city" });
|
||||
this.assertSize(city, 2);
|
||||
this.assertEqual(city[1].label, "City");
|
||||
this.assertApprox(city[1].multiplier, 2);
|
||||
|
||||
const capital = gdpPerCapitaFactors(plains, { cityKind: "capital" });
|
||||
const capital = productionFactors(plains, { cityKind: "capital" });
|
||||
this.assertApprox(capital[1].multiplier, 2.5);
|
||||
|
||||
const near = gdpPerCapitaFactors(plains, { nextToCity: true });
|
||||
const near = productionFactors(plains, { nextToCity: true });
|
||||
this.assertEqual(near[1].label, "Near city");
|
||||
this.assertApprox(near[1].multiplier, 1.3);
|
||||
}
|
||||
|
||||
test_gdp_per_capita_factors_include_the_marks_of_war() {
|
||||
test_production_factors_include_the_marks_of_war() {
|
||||
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
|
||||
const factors = gdpPerCapitaFactors(plains, { pillagePenalty: 0.7, battleDeficit: 500 });
|
||||
const factors = productionFactors(plains, { pillagePenalty: 0.7, battleDeficit: 500 });
|
||||
this.assertEqual(factors[1].label, "Pillaging");
|
||||
this.assertApprox(factors[1].multiplier, 0.7);
|
||||
this.assertEqual(factors[2].label, "Battle damage");
|
||||
this.assertApprox(factors[2].amount, -500);
|
||||
}
|
||||
|
||||
test_gdp_per_capita_from_factors_applies_in_order_and_floors_at_zero() {
|
||||
test_production_from_factors_applies_in_order_and_floors_at_zero() {
|
||||
this.assertApprox(
|
||||
gdpPerCapitaFromFactors(10000, [{ multiplier: 2 }, { amount: -500 }]),
|
||||
productionFromFactors(10000, [{ multiplier: 2 }, { amount: -500 }]),
|
||||
19500
|
||||
);
|
||||
this.assertEqual(gdpPerCapitaFromFactors(100, [{ amount: -500 }]), 0);
|
||||
this.assertEqual(productionFromFactors(100, [{ amount: -500 }]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { smallState, mapLayers, SEED } from "./framework/helpers.js";
|
||||
import { parseKey } from "../shared/hex.js";
|
||||
import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js";
|
||||
import { parseKey, key } from "../shared/hex.js";
|
||||
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
|
||||
import { MapView } from "../client/js/map_view.js";
|
||||
|
||||
@@ -28,44 +28,18 @@ export class SiegeTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// The supply check only runs when a hostile is adjacent: the daily refresh
|
||||
// never calls into the search otherwise.
|
||||
test_the_check_only_triggers_with_an_adjacent_hostile() {
|
||||
const state = smallState();
|
||||
const unit = state.units[0];
|
||||
const neighbours = state.topology.neighbours(unit.coords.x, unit.coords.y).filter(
|
||||
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
||||
);
|
||||
this.assertGreaterOrEqual(neighbours.length, 2, "the capital has open land around it");
|
||||
|
||||
let calls = 0;
|
||||
const original = state._unitStatuses.bind(state);
|
||||
state._unitStatuses = (...args) => {
|
||||
calls += 1;
|
||||
return original(...args);
|
||||
};
|
||||
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(calls, 0, "no hostile, no supply search");
|
||||
|
||||
state._spawnUnit(neighbours[0], 0, state.protoUnits[0]);
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(calls, 0, "a friendly neighbour does not trigger it");
|
||||
|
||||
state._spawnUnit(neighbours[1], 1, state.protoUnits[0]);
|
||||
state._refreshStatuses();
|
||||
this.assertGreater(calls, 0, "a hostile neighbour triggers the check");
|
||||
this.assertEmpty(unit.statuses, "the unit is still supplied");
|
||||
}
|
||||
|
||||
test_a_cut_off_unit_with_a_hostile_neighbour_is_encircled() {
|
||||
test_a_gap_in_the_ring_frees_the_unit() {
|
||||
const state = smallState();
|
||||
const tile = findSurroundableTile(state);
|
||||
this.assertNotNull(tile, "found an interior tile to surround");
|
||||
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
||||
surroundTile(state, tile, 1);
|
||||
const blockers = surroundTile(state, tile, 1);
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "a full ring encircles");
|
||||
|
||||
// Opening one land tile -- an allied unit or an empty tile -- breaks it.
|
||||
state._destroyUnit(blockers[0]);
|
||||
state._refreshStatuses();
|
||||
this.assertEmpty(unit.statuses, "one open land tile frees the unit");
|
||||
}
|
||||
|
||||
test_a_friendly_neighbour_does_not_trigger_encirclement() {
|
||||
@@ -106,13 +80,13 @@ export class SiegeTest extends TestCase {
|
||||
this.assertApprox(unit.hp, before - unit.maxHp * 0.02);
|
||||
}
|
||||
|
||||
test_a_lone_city_can_never_be_besieged() {
|
||||
test_a_lone_city_can_be_besieged() {
|
||||
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
|
||||
const city = state.cities[0];
|
||||
this.assertNotNull(city);
|
||||
surroundTile(state, city.coords, 1);
|
||||
state._refreshStatuses();
|
||||
this.assertEmpty(city.statuses, "a city with no sibling is not besieged");
|
||||
this.assertEqual(city.statuses, [STATUS_BESIEGED], "a surrounded portless city is besieged");
|
||||
}
|
||||
|
||||
test_status_is_refreshed_once_a_day_not_every_snapshot() {
|
||||
@@ -136,35 +110,32 @@ export class SiegeTest extends TestCase {
|
||||
this.assertEmpty(unit.statuses, "the daily refresh freed the unit");
|
||||
}
|
||||
|
||||
test_an_allied_naval_unit_lifts_a_siege_across_water() {
|
||||
test_a_port_survives_a_land_ring_and_falls_to_a_blockade() {
|
||||
const state = smallState();
|
||||
const spot = findCoastalPair(state);
|
||||
this.assertNotNull(spot, "found a sea tile between two land tiles");
|
||||
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
|
||||
surroundTile(state, spot.l1, 1);
|
||||
const city = findCoastalClearCity(state, 0);
|
||||
this.assertNotNull(city, "found a coastal city with a clear land ring");
|
||||
grantBuilding(state, city, "port");
|
||||
surroundTile(state, city.coords, 1);
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "cut off by land");
|
||||
this.assertEmpty(city.statuses, "the port supplies the city by sea");
|
||||
|
||||
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
|
||||
state._rebuildCityIndex();
|
||||
// Enemy warships now hold every sea tile around the city.
|
||||
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
|
||||
const tile = state.tiles[key(neighbour.x, neighbour.y)];
|
||||
if (tile && tile.terrainClass === "Sea") state._spawnUnit(neighbour, 1, navalProto());
|
||||
}
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "no lane without a ship");
|
||||
|
||||
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 0, navalProto());
|
||||
state._refreshStatuses();
|
||||
this.assertEmpty(unit.statuses, "the friendly navy reopens the lane");
|
||||
this.assertEqual(city.statuses, [STATUS_BESIEGED], "the blockade cuts the sea lane");
|
||||
}
|
||||
|
||||
test_an_enemy_naval_unit_does_not_open_a_lane() {
|
||||
test_an_allied_naval_unit_does_not_break_a_land_ring() {
|
||||
const state = smallState();
|
||||
const spot = findCoastalPair(state);
|
||||
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
|
||||
surroundTile(state, spot.l1, 1);
|
||||
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
|
||||
state._rebuildCityIndex();
|
||||
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 1, navalProto());
|
||||
const tile = findSurroundableTile(state);
|
||||
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
||||
surroundTile(state, tile, 1);
|
||||
state._spawnUnit(tile, 0, navalProto()); // a friendly hull on the same land tile
|
||||
state._refreshStatuses();
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "an enemy fleet is no supply line");
|
||||
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "only a port breaks the ring");
|
||||
}
|
||||
|
||||
async test_the_client_draws_status_icons_over_units_and_cities() {
|
||||
@@ -238,7 +209,22 @@ function surroundTile(state, coords, civ) {
|
||||
function findBesiegableCity(state, civ) {
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
if (state.unitAt(city.coords)) continue;
|
||||
// A coastal city now starts with a port, which keeps it supplied; besiege
|
||||
// an inland city instead.
|
||||
if (state.isCoastalCity(city)) continue;
|
||||
const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
|
||||
if (land.length === 0) continue;
|
||||
if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
|
||||
return city;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A coastal city whose land ring is clear, so a port's sea lane can be tested.
|
||||
function findCoastalClearCity(state, civ) {
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
if (!state.isCoastalCity(city)) continue;
|
||||
const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
|
||||
if (land.length === 0) continue;
|
||||
if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
|
||||
|
||||
@@ -135,7 +135,7 @@ export class PillageTest extends TestCase {
|
||||
this.assertTrue(state.requestPillage([unit.id]));
|
||||
|
||||
this.assertApprox(state.tilePopulation.get(k), 90000, 1);
|
||||
this.assertApprox(state.tileGdpPenalty.get(k), 0.7, 1e-9);
|
||||
this.assertApprox(state.tileProductionPenalty.get(k), 0.7, 1e-9);
|
||||
this.assertApprox(state.getTileGdpPerCapita(coords), gdpBefore * 0.7, 1e-6);
|
||||
this.assertApprox(unit.hp, 110, 1e-9, "heals 1 HP per 1000 people removed");
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export class PillageTest extends TestCase {
|
||||
this.assertTrue(state.requestPillage([strong.id, weak.id]));
|
||||
|
||||
this.assertApprox(state.tilePopulation.get(k), 81000, 1, "90% of 90% remains");
|
||||
this.assertApprox(state.tileGdpPenalty.get(k), 0.49, 1e-9, "70% of 70% remains");
|
||||
this.assertApprox(state.tileProductionPenalty.get(k), 0.49, 1e-9, "70% of 70% remains");
|
||||
this.assertApprox(weak.hp, strong.maxHp / 2 + 10, 1e-9, "the weakest pillages first");
|
||||
this.assertApprox(strong.hp, strong.maxHp - 100 + 9, 1e-9, "the second heals for the 9000 it removed");
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@ import { TERRAIN_TILES } from "../shared/data.js";
|
||||
export class TerrainStatsTest extends TestCase {
|
||||
test_authored_values_are_read() {
|
||||
const stats = new TerrainStats({
|
||||
"0,0": { populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5 },
|
||||
"0,0": { populationMultiplier: 0.3, productionMultiplier: 0.5, movementCostMultiplier: 1.5 },
|
||||
});
|
||||
this.assertApprox(stats.populationMultiplier({ x: 0, y: 0 }), 0.3);
|
||||
this.assertApprox(stats.gdpMultiplier({ x: 0, y: 0 }), 0.5);
|
||||
this.assertApprox(stats.productionMultiplier({ x: 0, y: 0 }), 0.5);
|
||||
this.assertApprox(stats.movementCostMultiplier({ x: 0, y: 0 }), 1.5);
|
||||
}
|
||||
|
||||
test_unset_values_fall_back() {
|
||||
const stats = new TerrainStats({ "0,0": {} });
|
||||
this.assertApprox(stats.populationMultiplier({ x: 0, y: 0 }), 0.0);
|
||||
this.assertApprox(stats.gdpMultiplier({ x: 0, y: 0 }), 0.0);
|
||||
this.assertApprox(stats.productionMultiplier({ x: 0, y: 0 }), 0.0);
|
||||
this.assertApprox(stats.movementCostMultiplier({ x: 0, y: 0 }), DEFAULT_MOVEMENT_COST_MULTIPLIER);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export class TerrainStatsTest extends TestCase {
|
||||
this.assertNotEmpty(land);
|
||||
for (const tile of land) {
|
||||
this.assertHas(tile, "populationMultiplier");
|
||||
this.assertHas(tile, "gdpMultiplier");
|
||||
this.assertHas(tile, "productionMultiplier");
|
||||
this.assertHas(tile, "movementCostMultiplier");
|
||||
}
|
||||
const cheapest = Math.min(...TERRAIN_TILES.map((t) => t.movementCostMultiplier || DEFAULT_MOVEMENT_COST_MULTIPLIER));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { smallState, cityOf, stripPorts } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { TRENCH } from "../shared/data/combat.js";
|
||||
|
||||
@@ -47,6 +47,8 @@ export class TrenchTest extends TestCase {
|
||||
|
||||
test_trenches_multiply_defense_and_cut_gdp() {
|
||||
const state = smallState();
|
||||
// A seeded port adds flat GDP that the trench multiplier would not scale.
|
||||
stripPorts(state, 0);
|
||||
const coords = ownLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
const before = state.getTileGdpPerCapita(coords);
|
||||
@@ -54,7 +56,7 @@ export class TrenchTest extends TestCase {
|
||||
state._gdpPerCapitaCache.delete(k);
|
||||
this.assertApprox(
|
||||
state.getTileGdpPerCapita(coords),
|
||||
before * TRENCH.gdpMultiplier,
|
||||
before * TRENCH.productionMultiplier,
|
||||
1e-6,
|
||||
"a trench costs the tile a fifth of its GDP per capita"
|
||||
);
|
||||
|
||||