From af69541102c27461d88a627224bce4d7181e6be7 Mon Sep 17 00:00:00 2001 From: Ricky Date: Sat, 25 Jul 2026 09:03:59 +0200 Subject: [PATCH] feat!: ship types, complete the models, and add production, flight and combat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the library from "mine costs" to most of the maths a calculator needs, and fixes the naming and data problems that had accumulated. Models - Buildings goes from 5 to 19 entries: storages, facilities and moon buildings. - New Research model with the 16 technologies, Astrophysics rounding included. - Every entry carries `names: { en, fr }` and `ogameId`, the id the game uses. - `base` fields are named after what they are: `energyCost` (paid to build), `energyConsumption` (consumed once built) and `deuteriumConsumption` replace the `energy`/`consumption` pair that meant different things per building. - `deutCost` held half the real fuel consumption on all 15 ships; corrected and renamed `fuelConsumption`. Ships also gained `drive` and `driveUpgrades`. - models.test.js asserts the invariants every entry must hold, so a future entry that is incomplete or inconsistent fails CI. Calculators - The five mine and plant calculators no longer reimplement `base * factor ** (level - 1)`; they share cost.js and info.js, and all return the same seven fields. Behaviour is unchanged, verified against the previous expectations. - They take the whole entry instead of its `base`, like everything else, and throw a message pointing at the fix when handed a `base`. - New: getBuildingCost, getBuildTime, getStorage, getPlanetProduction, getProductionBonus, getResearchCost, getResearchTime. Fleets - getDistance, getShipSpeed, getFleetSpeed, getActiveDrive, getFlightTime, getFuelConsumption, getTrip. - simulateCombat: six rounds, rapid fire, shield bounce and explosion odds, seedable so a battle can be replayed and averaged. Types - TypeScript declarations generated from the JSDoc into types/, wired through the exports map and built by prepack. Verified against a strict TS consumer. Also - The exports map only allowed the package root, so the subpath import the README documented never worked. Named subpaths added. - README and CONTRIBUTING rewritten for the new surface, and MIGRATION.md added. - infocompte reads its language from the report, accepts custom labels, throws readable errors, and returns numbers for mine levels. BREAKING CHANGE: mine and plant calculators now take `Buildings[id]` rather than `Buildings[id].base`, and return `energyCost`/`energyConsumption`/ `deuteriumConsumption` instead of `energy`/`consumption`. BREAKING CHANGE: model fields renamed — `entry.name` to `entry.names.fr`, `base.deutrium` to `base.deuterium`, `base.energy` to `base.energyConsumption` or `base.energyCost`, `base.consumption` to `base.deuteriumConsumption`, `fret` to `cargo`, `cost.deut` to `cost.deuterium`, and `deutCost` to `fuelConsumption` with corrected values (they were half the in-game figure). BREAKING CHANGE: parseInfoCompteData returns planet mine levels as numbers instead of strings, and getDebris returns an extra `deuterium` key. See MIGRATION.md for the full upgrade path. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/types/action.yaml | 10 + .github/workflows/main.yaml | 11 + .gitignore | 4 + CONTRIBUTING.md | 14 +- MIGRATION.md | 116 +++++++ README.md | 438 +++++++++++++++++++++------ package-lock.json | 26 +- package.json | 53 +++- src/buildings/buildTime.js | 25 ++ src/buildings/buildTime.test.js | 21 ++ src/buildings/crystal.js | 54 ++-- src/buildings/crystal.test.js | 12 +- src/buildings/deut.js | 53 ++-- src/buildings/deut.test.js | 12 +- src/buildings/fusion-reactor.js | 53 ++-- src/buildings/fusion-reactor.test.js | 7 +- src/buildings/index.js | 11 + src/buildings/info.js | 42 +++ src/buildings/infocompte.js | 173 +++++++---- src/buildings/infocompte.test.js | 103 ++++++- src/buildings/metal.js | 41 +-- src/buildings/metal.test.js | 12 +- src/buildings/production.js | 150 +++++++++ src/buildings/production.test.js | 111 +++++++ src/buildings/solar-plant.js | 34 +-- src/buildings/solar-plant.test.js | 6 +- src/buildings/storage.js | 59 ++++ src/buildings/storage.test.js | 56 ++++ src/cost.js | 70 +++++ src/cost.test.js | 78 +++++ src/fleets/combat.js | 291 ++++++++++++++++++ src/fleets/combat.test.js | 175 +++++++++++ src/fleets/distance.js | 39 +++ src/fleets/distance.test.js | 36 +++ src/fleets/flight.js | 105 +++++++ src/fleets/flight.test.js | 91 ++++++ src/fleets/getDebris.js | 21 +- src/fleets/getDebris.test.js | 16 +- src/fleets/index.js | 12 + src/fleets/speed.js | 108 +++++++ src/fleets/speed.test.js | 107 +++++++ src/i18n.js | 43 +++ src/i18n.test.js | 62 ++++ src/index.js | 13 +- src/models/buildings.js | 310 +++++++++++++++++-- src/models/destroyable.js | 326 +++++++++++++------- src/models/models.test.js | 139 +++++++++ src/models/research.js | 169 +++++++++++ src/research/index.js | 9 + src/research/researchTime.js | 25 ++ src/research/researchTime.test.js | 18 ++ src/types.js | 146 +++++++++ tsconfig.json | 21 ++ 53 files changed, 3634 insertions(+), 503 deletions(-) create mode 100644 .github/actions/types/action.yaml create mode 100644 MIGRATION.md create mode 100644 src/buildings/buildTime.js create mode 100644 src/buildings/buildTime.test.js create mode 100644 src/buildings/info.js create mode 100644 src/buildings/production.js create mode 100644 src/buildings/production.test.js create mode 100644 src/buildings/storage.js create mode 100644 src/buildings/storage.test.js create mode 100644 src/cost.js create mode 100644 src/cost.test.js create mode 100644 src/fleets/combat.js create mode 100644 src/fleets/combat.test.js create mode 100644 src/fleets/distance.js create mode 100644 src/fleets/distance.test.js create mode 100644 src/fleets/flight.js create mode 100644 src/fleets/flight.test.js create mode 100644 src/fleets/speed.js create mode 100644 src/fleets/speed.test.js create mode 100644 src/i18n.js create mode 100644 src/i18n.test.js create mode 100644 src/models/models.test.js create mode 100644 src/models/research.js create mode 100644 src/research/index.js create mode 100644 src/research/researchTime.js create mode 100644 src/research/researchTime.test.js create mode 100644 src/types.js create mode 100644 tsconfig.json diff --git a/.github/actions/types/action.yaml b/.github/actions/types/action.yaml new file mode 100644 index 0000000..cea6557 --- /dev/null +++ b/.github/actions/types/action.yaml @@ -0,0 +1,10 @@ +name: Types +description: Type check the sources and build the declaration files + +runs: + using: "composite" + steps: + - run: npm ci + shell: bash + - run: npm run types + shell: bash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2bcee5a..8700e3a 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -18,6 +18,17 @@ jobs: cache: npm - uses: ./.github/actions/lint + types: + name: Types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - uses: ./.github/actions/types + test: name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6194f9c..2b625db 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,7 @@ typings/ .dynamodb/ build/ + +# Generated TypeScript declarations, at the root only — a bare `types/` would +# also swallow .github/actions/types/ +/types/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13ff6b0..e41f3f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,12 +20,24 @@ Requires **Node.js >= 24** (see `.nvmrc`). ```bash npm run lint npm test + npm run types # type checks the JSDoc and writes types/ ``` 4. Commit using **[Conventional Commits](https://www.conventionalcommits.org/)** — the version and changelog are derived from them. You can use the interactive helper: ```bash npm run commit ``` -5. Open a pull request against `master`. CI runs lint and tests on every PR. +5. Open a pull request against `master`. CI runs lint, the type check and the tests on every PR. + +## Adding game data + +The models in `src/models/` are covered by `src/models/models.test.js`, which +asserts the invariants every entry must hold — a complete `base`, a known +category and drive, rapid-fire targets that exist, `structure` equal to the metal +plus crystal cost, no duplicate `ogameId`. Adding an entry that breaks one of +those fails CI, so start there when the shape is unclear. + +Both `names.en` and `names.fr` are required; a missing translation fails +`src/i18n.test.js`. ## Commit conventions diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..5113f15 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,116 @@ +# Migration guide + +## 3.x → 4.0 + +4.0 renames the ambiguous fields of the models, unifies the calculator +signatures, and corrects one piece of wrong game data. Every change below is +mechanical, and the errors thrown by the new code point at the fix. + +### Calculators take the entry, not its `base` + +The mine and plant calculators used to take `Buildings[id].base`. They now take +the whole `Buildings[id]`, like every other calculator, because they read the +cost `factor` that lives on the entry. + +```diff +- Ogame.Building.getMetalMine(Ogame.models.Buildings[1].base, 10, 5); ++ Ogame.Building.getMetalMine(Ogame.models.Buildings[1], 10, 5); +``` + +Passing a `base` throws an error that says exactly this, so a test run finds +every call site for you. + +### `base.energy` split in three + +`base.energy` meant "energy consumed" on a mine and "energy paid to build" on +the Terraformer. `base.consumption` meant "deuterium burned". They are now named +after what they are: + +| 3.x | 4.0 | +| ------------------ | ------------------------------- | +| `base.energy` | `base.energyConsumption`, or `base.energyCost` on the Terraformer and the Space Dock | +| `base.consumption` | `base.deuteriumConsumption` | +| `base.deutrium` | `base.deuterium` | + +The `energyIsCost` flag that 3.x used internally is gone — it existed only to +tell those two meanings apart. + +### Calculators return one shape + +Every building calculator now returns the same seven fields, `0` where a field +does not apply. The `energy` and `consumption` keys of the returned object are +gone: + +```diff + { + metal, crystal, deuterium, +- energy, // consumption for a mine, 0 for a plant +- consumption, // fusion reactor only ++ energyCost, // energy paid to build it ++ energyConsumption, // energy it consumes once built ++ deuteriumConsumption, // deuterium it burns once built + production, + } +``` + +`getBuildingCost` and `getResearchCost` return the four cost fields only, with +`energy` renamed to `energyCost`. + +### Models: renamed and corrected fields + +| 3.x | 4.0 | +| ---------------------------- | ---------------------- | +| `entry.name` | `entry.names.fr` | +| `Destroyable[id].fret` | `Destroyable[id].cargo` | +| `Destroyable[id].cost.deut` | `Destroyable[id].cost.deuterium` | +| `Destroyable[id].deutCost` | `Destroyable[id].fuelConsumption` — **and the values changed**, see below | + +**`deutCost` held half the real fuel consumption.** All fifteen ships were +consistently at half the in-game value (light fighter 10 instead of 20, cruiser +150 instead of 300). `fuelConsumption` carries the correct values. If you had +compensated for this by doubling somewhere, remove that. + +`Destroyable[301]` and `[302]` moved from the `defenses` category to `missiles`, +which is what `ATTRIBUTES.CATEGORIES.MISSILE` was always meant for. + +### `parseInfoCompteData` returns numbers + +Planet mine levels came back as strings while `temperature` in the same object +was a number. They are numbers now. + +```diff +- report.planets[0].metal // '36' ++ report.planets[0].metal // 36 +``` + +The parser also throws readable errors instead of a `TypeError` when a section +is missing, reads the report language from its header, and accepts +`{ locale }` / `{ labels }` options. + +### `getDebris` gained a key and an argument + +```diff +- Ogame.Fleets.getDebris(ship, 100, 0.3) // { metal, crystal } ++ Ogame.Fleets.getDebris(ship, 100, 0.3) // { metal, crystal, deuterium } +``` + +A fourth argument, `deuteriumFactor`, covers the universes that put deuterium in +debris fields. A `toEqual` on the old two-key object needs updating. + +### New in 4.0 + +Nothing below breaks anything; it is what the major bought. + +- **TypeScript declarations**, generated from the JSDoc and shipped in `types/`. +- `Ogame.models.Buildings` went from 5 to 19 buildings, and there is now a + `Ogame.models.Research` with the 16 technologies. +- `Ogame.Research`, `Ogame.i18n` namespaces. +- `getBuildingCost`, `getBuildTime`, `getStorage`, `getPlanetProduction`, + `getProductionBonus`, `getResearchCost`, `getResearchTime`. +- `Ogame.Fleets`: `getDistance`, `getShipSpeed`, `getFleetSpeed`, + `getActiveDrive`, `getFlightTime`, `getFuelConsumption`, `getTrip`, + `simulateCombat`. +- `names: { en, fr }` on every model entry, and `ogameId` to map back to the game. +- Subpath exports: `ogamejs/buildings`, `ogamejs/models/research`, `ogamejs/i18n`, … + The `ogamejs/src/buildings/index.js` form documented in 3.x never actually + worked, the `exports` map blocked it. diff --git a/README.md b/README.md index 3cbeffa..2cc23a4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # ogamejs -A small, dependency-free JavaScript library that reproduces [OGame](https://gameforge.com/en-GB/play/ogame)'s core formulas: building costs and production, fleet debris, and the marketplace exchange rates. +A small, dependency-free JavaScript library that reproduces [OGame](https://gameforge.com/en-GB/play/ogame)'s formulas: building and research costs, production and bonuses, build and research times, storage capacity, flight times and fuel, combat, and the marketplace exchange rates. -It ships only the math — no UI, no state — so you can build your own calculator, bot, or dashboard on top of it. +It ships only the math — no UI, no state, no network — so you can build your own calculator, bot, or dashboard on top of it. ## Requirements - **Node.js >= 24** - The package is **ESM-only** (use `import`, not `require`) +- TypeScript declarations are bundled; no `@types` package needed ## Installation @@ -15,190 +16,452 @@ It ships only the math — no UI, no state — so you can build your own calcula npm install ogamejs ``` +> Upgrading from 3.x? See [MIGRATION.md](./MIGRATION.md) — 4.0 renames several model fields and changes the calculator signatures. + ## Quick start ```javascript import Ogame from 'ogamejs'; -// Reference data bundled with the library -const { Buildings, Destroyable } = Ogame.models; - -// Level 10 metal mine on a universe with speed x5 -const metalBase = Buildings[1].base; -const mine = Ogame.Building.getMetalMine(metalBase, 10, 5); -// → { crystal: 576, deuterium: 0, energy: 259, metal: 2306, production: 3890 } - -// Debris field left by 100 light fighters (universe debris factor 0.3) -const debris = Ogame.Fleets.getDebris(Destroyable[1], 100, 0.3); -// → { metal: 90000, crystal: 30000 } +const { Buildings, Destroyable, Research } = Ogame.models; + +// A level 10 metal mine on a x5 universe +Ogame.Building.getMetalMine(Buildings[1], 10, 5); +// → { metal: 2306, crystal: 576, deuterium: 0, energyCost: 0, +// energyConsumption: 259, deuteriumConsumption: 0, production: 3890 } + +// What a whole planet actually makes per hour +Ogame.Building.getPlanetProduction( + { metalMine: 30, crystalMine: 26, deutSynth: 24, position: 8, temperature: -23, universeSpeed: 6 }, + { plasmaTech: 15, geologist: true }, +); +// → { metal: 117962, crystal: 44673, deuterium: 23668, energyConsumption: 13059, bonus: {...} } + +// A raid: how long, and how much deuterium +Ogame.Fleets.getTrip( + [{ ship: Destroyable[12], count: 250 }, { ship: Destroyable[9], count: 40 }], + { galaxy: 1, system: 1, position: 8 }, + { galaxy: 3, system: 42, position: 6 }, + { drives: { combustion: 16, hyperspace: 12 } }, +); +// → { distance: 40000, fleetSpeed: 19500, duration: 1595, fuel: 258287, +// cargo: 6650000, cargoAfterFuel: 6391713 } + +// And how it would go +Ogame.Fleets.simulateCombat( + { fleet: [{ ship: Destroyable[1], count: 500 }], techs: { weapons: 14, shielding: 12, armour: 15 } }, + { fleet: [{ ship: Destroyable[201], count: 200 }], techs: { weapons: 10 } }, + { seed: 42 }, +); +// → { winner: 'attacker', rounds: 3, seed: 42, attacker: {...}, defender: {...}, debris: {...} } // Sell 10 000 deuterium at the default 2:1.5:1 rate -const trade = Ogame.Trader.sellDeut(10000); +Ogame.Trader.sellDeut(10000); // → { metal: 12000, crystal: 6000 } ``` -`Ogame` is the default export and exposes four namespaces: +`Ogame` is the default export and exposes these namespaces: -| Namespace | Purpose | -| ----------------- | ----------------------------------------------------------- | -| `Ogame.Building` | Building cost & production calculators | -| `Ogame.Fleets` | Fleet-related calculations | -| `Ogame.Trader` | Marketplace exchange helpers | -| `Ogame.models` | Reference data (`Buildings`, `Destroyable`) | +| Namespace | Purpose | +| ---------------- | -------------------------------------------------------------- | +| `Ogame.Building` | Building costs, production, build times, storage | +| `Ogame.Research` | Technology costs and research times | +| `Ogame.Fleets` | Distance, speed, flight time, fuel, debris, combat | +| `Ogame.Trader` | Marketplace exchange helpers | +| `Ogame.i18n` | Localised names (`getName`, `findByName`) | +| `Ogame.models` | Reference data (`Buildings`, `Destroyable`, `Research`) | -You can also import a single namespace directly: +Each namespace is also importable on its own: ```javascript -import Building from 'ogamejs/src/buildings/index.js'; +import Building from 'ogamejs/buildings'; +import Fleets from 'ogamejs/fleets'; +import Research from 'ogamejs/research'; +import Trader from 'ogamejs/trades'; +import BUILDINGS from 'ogamejs/models/buildings'; +import { getName } from 'ogamejs/i18n'; ``` +## Conventions + +A few rules hold across the whole library, so you rarely have to check the docs twice. + +**Calculators take a model entry**, never its `base`. `getMetalMine(Buildings[1], 10)`, not `Buildings[1].base` — the entry carries the cost `factor` the calculator needs. + +**Levels start at 1.** A cost is the cost *of* that level, not the sum up to it. Anything below 1 throws. + +**Energy and deuterium flows are named after what they are.** No field means two things depending on the building: + +| Field | Meaning | +| ---------------------- | ---------------------------------------------- | +| `metal`/`crystal`/`deuterium` | Resources paid to build it | +| `energyCost` | Energy paid to build it (Terraformer, Space Dock, Graviton) | +| `energyConsumption` | Energy it consumes once built (the mines) | +| `deuteriumConsumption` | Deuterium it burns once built (Fusion Reactor) | +| `production` | What it produces, resources or energy | + +**Every building calculator returns the same seven fields**, `0` where one does not apply. So you can read `production` off a result without knowing which building produced it. + +**Times are in seconds**, production is per hour. + ## API reference ### `Ogame.Building` -Every mine/plant calculator takes a **base** object (from `Ogame.models.Buildings[id].base`) and returns the cost to reach `targetLevel` plus the resulting production. +#### `getMetalMine(building, targetLevel, universeSpeed = 1)` + +```javascript +Ogame.Building.getMetalMine(Ogame.models.Buildings[1], 10, 5); +// → { metal: 2306, crystal: 576, deuterium: 0, energyCost: 0, +// energyConsumption: 259, deuteriumConsumption: 0, production: 3890 } +``` + +#### `getCrystalMine(building, targetLevel, pos, universeSpeed = 1)` + +`pos` is the planet position; 1, 2 and 3 produce 30%, 22.5% and 15% more crystal. + +```javascript +Ogame.Building.getCrystalMine(Ogame.models.Buildings[2], 10, 1, 5); +// → { ..., energyConsumption: 259, production: 3371 } +``` + +#### `getDeutSynth(building, targetLevel, avg, universeSpeed = 1)` -The returned object always has the same shape: +`avg` is the planet's average temperature — the colder the planet, the more deuterium. ```javascript -{ - metal: Number, // metal cost to reach targetLevel - crystal: Number, // crystal cost to reach targetLevel - deuterium: Number, // deuterium cost to reach targetLevel - energy: Number, // energy consumption at targetLevel - production: Number, // resource (or energy) produced at targetLevel -} +Ogame.Building.getDeutSynth(Ogame.models.Buildings[3], 10, 40, 5); +// → { ..., energyConsumption: 518, production: 1554 } ``` -#### `getMetalMine(base, targetLevel, universeSpeed = 1)` +#### `getSolarPlant(building, targetLevel)` ```javascript -Ogame.Building.getMetalMine(Ogame.models.Buildings[1].base, 10, 5); -// → { crystal: 576, deuterium: 0, energy: 259, metal: 2306, production: 3890 } +Ogame.Building.getSolarPlant(Ogame.models.Buildings[4], 10); +// → { ..., production: 518 } // production is energy here ``` -#### `getCrystalMine(base, targetLevel, pos, universeSpeed = 1)` +#### `getFusionReactor(building, targetLevel, energyTech, universeSpeed = 1)` -`pos` is the planet position (1, 2 or 3), which grants a production bonus (positions closer to the sun produce more crystal). +`production` is the energy delivered, `deuteriumConsumption` the deuterium burned per hour. ```javascript -Ogame.Building.getCrystalMine(Ogame.models.Buildings[2].base, 10, 1, 5); -// → { crystal: 1649, deuterium: 0, energy: 259, metal: 3298, production: 3371 } +Ogame.Building.getFusionReactor(Ogame.models.Buildings[5], 10, 12, 5); +// → { metal: 178523, crystal: 71409, deuterium: 35704, energyCost: 0, +// energyConsumption: 0, deuteriumConsumption: 1296, production: 1442 } ``` -#### `getDeutSynth(base, targetLevel, avg, universeSpeed = 1)` +#### `getBuildingCost(entry, targetLevel)` -`avg` is the planet's average temperature — the colder the planet, the higher the deuterium production. +Cost of **any** building at a given level, from its `factor`. Also accepts `Research` entries — `Ogame.Research.getResearchCost` is this very function. ```javascript -Ogame.Building.getDeutSynth(Ogame.models.Buildings[3].base, 10, 40, 5); -// → { crystal: 2883, deuterium: 0, energy: 518, metal: 8649, production: 1554 } +Ogame.Building.getBuildingCost(Ogame.models.Buildings[21], 12); // Shipyard +// → { metal: 819200, crystal: 409600, deuterium: 204800, energyCost: 0 } ``` -#### `getSolarPlant(base, targetLevel)` +#### `getBuildTime(building, targetLevel, roboticsLevel = 0, naniteLevel = 0, universeSpeed = 1)` -Produces energy, so `production` is an energy amount and `energy` (consumption) is `0`. +`(metal + crystal) / (2500 × (1 + robotics) × 2 ** nanites)` hours, divided by the universe economy speed. ```javascript -Ogame.Building.getSolarPlant(Ogame.models.Buildings[4].base, 10); -// → { crystal: 1153, deuterium: 0, energy: 0, metal: 2883, production: 518 } +Ogame.Building.getBuildTime(Ogame.models.Buildings[15], 4, 10, 3, 6); // Nanite factory +// → 32727 ``` -#### `getFusionReactor(base, targetLevel, energyTech, universeSpeed = 1)` +#### `getStorage(storage, targetLevel)` -`energyTech` is the Energy Technology level. The returned object additionally includes `consumption` (deuterium burned per hour): +Cost **and** capacity of a storage building (`Buildings[22]`, `[23]` or `[24]`). ```javascript -{ - metal: Number, - crystal: Number, - deuterium: Number, // deuterium cost to build - energy: Number, // always 0 (it produces energy) - consumption: Number, // deuterium consumed at targetLevel - production: Number, // energy produced at targetLevel -} +Ogame.Building.getStorage(Ogame.models.Buildings[22], 12); +// → { metal: 2048000, crystal: 0, deuterium: 0, energyCost: 0, capacity: 18005000 } ``` -#### `parseInfoCompteData(data)` +#### `getStorageCapacity(level)` / `getStorageLevelFor(amount)` -Parses the BBCode of the French OGame "infocompte" report into a structured object. +`5000 × ⌊2.5 × e^(20 × level / 33)⌋`, and its inverse. + +```javascript +Ogame.Building.getStorageCapacity(0); // → 10000, the free capacity +Ogame.Building.getStorageLevelFor(1_000_000); // → 8 +``` + +#### `getProductionBonus(options = {})` + +The multiplier each resource gets. Bonuses **add up**, they do not compound. + +```javascript +Ogame.Building.getProductionBonus({ + plasmaTech: 15, // +1% metal, +0.66% crystal, +0.33% deuterium per level + geologist: true, // +10% on all three + collectorClass: true, // +25% on all three + items: { metal: 0.3 },// booster items, as fractions +}); +// → { metal: 1.8, crystal: 1.449, deuterium: 1.3995 } +``` + +#### `getPlanetProduction(planet, bonuses = {})` + +The hourly production of a whole planet: raw mine output, then the bonuses, then the flat planet income (30 metal and 15 crystal per hour × universe speed) which no bonus touches. + +```javascript +Ogame.Building.getPlanetProduction( + { metalMine: 30, crystalMine: 26, deutSynth: 24, position: 8, temperature: -23, universeSpeed: 6 }, + { plasmaTech: 15, geologist: true }, +); +// → { metal: 117962, crystal: 44673, deuterium: 23668, +// energyConsumption: 13059, bonus: { metal: 1.25, ... } } +``` + +A mine at level 0 simply produces nothing. Pass `energyEfficiency` (0 to 1) to model a planet running in an energy deficit — `energyConsumption` tells you what the mines ask for, so you can work it out against your plants. + +#### `parseInfoCompteData(data, options = {})` + +Parses the BBCode of an OGame "infocompte" report. The language comes from the `s165-fr` header. ```javascript const report = Ogame.Building.parseInfoCompteData(bbcodeString); // → { -// planets: [{ planet, metal, crystal, deut, temperature }, ...], +// planets: [{ planet: 'Planète 01', metal: 36, crystal: 31, deut: 31, temperature: -94 }, ...], // production: { hourly: {...}, daily: {...}, weekly: {...} }, // points: { metal, crystal, deut, total }, -// plasma: Number, -// universe: Number, -// lang: String, +// plasma: Number, universe: Number, lang: String, // } ``` -> Note: this parser expects a French-language report. +French is the reference language; English is best effort. For anything else, pass your own labels — see `LOCALES` in [`src/buildings/infocompte.js`](./src/buildings/infocompte.js): + +```javascript +Ogame.Building.parseInfoCompteData(bbcode, { locale: 'fr' }); +Ogame.Building.parseInfoCompteData(bbcode, { labels: { planet: 'Planet', /* ... */ } }); +``` + +### `Ogame.Research` + +#### `getResearchCost(research, targetLevel)` + +```javascript +Ogame.Research.getResearchCost(Ogame.models.Research[124], 5); // Astrophysics +// → { metal: 37600, crystal: 75100, deuterium: 37600, energyCost: 0 } +``` + +Astrophysics has the game's only non-integer factor (1.75) and its costs are rounded up to the nearest hundred; Graviton is paid entirely in `energyCost`. + +#### `getResearchTime(research, targetLevel, labLevel = 0, researchSpeed = 1)` + +`(metal + crystal) / (1000 × (1 + labs))` hours, divided by the universe research speed. `labLevel` is the Research Lab level, or the sum of every connected lab once the Intergalactic Research Network is up. + +```javascript +Ogame.Research.getResearchTime(Ogame.models.Research[122], 10, 12, 2); // Plasma +// → 425354 +``` ### `Ogame.Fleets` -#### `getDebris(ship, number, factor)` +#### `getDistance(origin, target)` -Returns the debris field generated when `number` ships (or defenses) of a given type are destroyed. `factor` is the universe's debris factor (e.g. `0.3` for 30%). Pass a full entry from `Ogame.models.Destroyable`. +Coordinates are `{ galaxy, system, position }`. The scale is not linear. ```javascript -Ogame.Fleets.getDebris(Ogame.models.Destroyable[1], 100, 0.3); -// → { metal: 90000, crystal: 30000 } +Ogame.Fleets.getDistance({ galaxy: 1, system: 1, position: 1 }, { galaxy: 4, system: 1, position: 1 }); +// → 60000 ``` -### `Ogame.Trader` +#### `getShipSpeed(ship, drives = {})` / `getFleetSpeed(fleet, drives = {})` / `getActiveDrive(ship, drives = {})` -Marketplace helpers to convert one resource into the two others. Rates are expressed as a `metal:crystal:deut` string (default `'2:1.5:1'`), and percentages control how the traded amount is split between the two target resources. **All parameters are optional.** +`drives` holds the `{ combustion, impulse, hyperspace }` levels. Each level adds 10%, 20% or 30% of the ship's base speed, and only the drive the ship actually flies on counts. -#### `sellDeut(deut = 0, percentM = 60, percentC = 40, rate = '2:1.5:1')` +A few ships switch to a better drive once the matching technology is high enough. `getActiveDrive` tells you which one is in use — and since a switch also changes the fuel burn, it reports that too. ```javascript -Ogame.Trader.sellDeut(10000); -// → { metal: 12000, crystal: 6000 } +Ogame.Fleets.getShipSpeed(Ogame.models.Destroyable[11], { combustion: 6 }); +// → 8000 +Ogame.Fleets.getActiveDrive(Ogame.models.Destroyable[11], { combustion: 6, impulse: 5 }); +// → { drive: 'impulse', speed: 20000, fuelConsumption: 20 } + +// A fleet moves at the speed of its slowest ship +Ogame.Fleets.getFleetSpeed([{ ship: Ogame.models.Destroyable[1], count: 100 }], { combustion: 10 }); +// → 25000 ``` -#### `sellMetal(metal = 0, percentD = 40, percentC = 60, rate = '2:1.5:1')` +#### `getFlightTime(distance, fleetSpeed, speedPercent = 100, universeFleetSpeed = 1)` + +`(10 + 35000 / speedPercent × √(distance × 10 / fleetSpeed)) / universeFleetSpeed`, in seconds. + +#### `getFuelConsumption(fleet, distance, speedPercent = 100, drives = {})` + +`1 + round(Σ(consumption × count) × distance / 35000 × (speedPercent / 100 + 1)²)`, in deuterium. + +#### `getTrip(fleet, origin, target, options = {})` + +The three above in one call, plus the cargo maths. ```javascript -Ogame.Trader.sellMetal(10000); -// → { deut: Number, crystal: Number } +Ogame.Fleets.getTrip( + [{ ship: Ogame.models.Destroyable[12], count: 250 }, { ship: Ogame.models.Destroyable[9], count: 40 }], + { galaxy: 1, system: 1, position: 8 }, + { galaxy: 3, system: 42, position: 6 }, + { drives: { combustion: 16, hyperspace: 12 }, speedPercent: 100, roundTrip: false }, +); +// → { distance: 40000, fleetSpeed: 19500, duration: 1595, fuel: 258287, +// cargo: 6650000, cargoAfterFuel: 6391713 } ``` -#### `sellCrystal(crystal = 0, percentD = 40, percentM = 60, rate = '2:1.5:1')` +`cargoAfterFuel` is what is left once the deuterium is loaded — the number that decides whether a raid is worth flying. + +#### `getDebris(ship, number, factor, deuteriumFactor = 0)` + +The debris field left by destroyed units. `factor` is the universe debris factor, `deuteriumFactor` its deuterium debris factor (`0` on most universes). ```javascript -Ogame.Trader.sellCrystal(10000); -// → { deut: Number, metal: Number } +Ogame.Fleets.getDebris(Ogame.models.Destroyable[1], 100, 0.3); +// → { metal: 90000, crystal: 30000, deuterium: 0 } +``` + +#### `simulateCombat(attacker, defender, options = {})` + +A full battle: up to six rounds, every unit fires once per round at a random enemy, rapid fire grants extra shots, shots below 1% of the target's shield bounce off, shields come back every round, and a unit under 70% hull may explode at the end of the round. + +```javascript +const battle = Ogame.Fleets.simulateCombat( + { + fleet: [{ ship: Ogame.models.Destroyable[1], count: 500 }], + techs: { weapons: 14, shielding: 12, armour: 15 }, + }, + { + fleet: [{ ship: Ogame.models.Destroyable[201], count: 200 }], + techs: { weapons: 10, shielding: 10, armour: 10 }, + }, + { seed: 42, debrisFactor: 0.3 }, +); +// → { +// winner: 'attacker' | 'defender' | 'draw', +// rounds: Number, +// seed: Number, +// attacker: { survivors: [{ ship, count }], losses: [{ ship, count }] }, +// defender: { survivors: [...], losses: [...] }, +// debris: { metal, crystal, deuterium }, +// } +``` + +A battle is **random**, so one run is one possible outcome. Pass a `seed` to replay the exact same fight; average several seeds to get a feel for the likely result: + +```javascript +const runs = Array.from({ length: 100 }, (_, seed) => simulateCombat(a, d, { seed })); +const winRate = runs.filter((run) => run.winner === 'attacker').length / runs.length; +``` + +Destroyed defenses are left out of the debris field unless you pass `defenseDebris: true`. Defense repair after a battle is not modelled. + +### `Ogame.Trader` + +Converts one resource into the two others. Rates are a `metal:crystal:deut` string (default `'2:1.5:1'`), and the percentages split the traded amount between the two target resources. **All parameters are optional.** + +```javascript +Ogame.Trader.sellDeut(10000); // → { metal: 12000, crystal: 6000 } +Ogame.Trader.sellMetal(10000); // → { deut, crystal } +Ogame.Trader.sellCrystal(10000); // → { deut, metal } ``` #### `parseRate(rate = '2:1.5:1', type = 'deut')` -Normalizes a rate string relative to a reference resource (`'metal'`, `'crystal'` or `'deut'`). Throws if the rate is malformed. +Normalizes a rate relative to a reference resource (`'metal'`, `'crystal'` or `'deut'`). Throws if the rate is malformed. ```javascript Ogame.Trader.parseRate('3:2:1', 'deut'); // → { rateMetal: 3, rateCrystal: 2, rateDeut: 1 } ``` +### `Ogame.i18n` + +Every model entry carries `names: { en, fr }`. + +```javascript +Ogame.i18n.getName(Ogame.models.Buildings[22]); // → 'Metal Storage' +Ogame.i18n.getName(Ogame.models.Buildings[22], 'fr'); // → 'Hangar de métal' + +// Case and accent insensitive, matches any supported language +Ogame.i18n.findByName(Ogame.models.Destroyable, 'etoile de la mort').ogameId; // → 214 +``` + ### `Ogame.models` Frozen reference datasets you feed into the calculators. -- **`Buildings`** — every building, keyed by in-game id, with its `name` and `base` stats. +- **`Buildings`** — the 19 buildings (mines, plants, storages, facilities, moon buildings). See [`src/models/buildings.js`](./src/models/buildings.js). -- **`Destroyable`** — every ship and defense, keyed by id, with structure, shield, attack, cost, rapid-fire table, etc. +- **`Destroyable`** — the 27 ships, defenses and missiles, with structure, shield, attack, speed, cargo, fuel, drive and rapid-fire table. See [`src/models/destroyable.js`](./src/models/destroyable.js). +- **`Research`** — the 16 technologies. + See [`src/models/research.js`](./src/models/research.js). +- **`ATTRIBUTES`** — the `TYPES`, `CATEGORIES` and `DRIVES` enums used by `Destroyable`. ```javascript -Ogame.models.Buildings[1]; -// → { name: 'Mine de métal', base: { production, consumption, metal, crystal, deutrium, energy } } +Ogame.models.Buildings[22]; +// → { +// ogameId: 22, +// names: { en: 'Metal Storage', fr: 'Hangar de métal' }, +// category: 'resources', +// factor: 2, +// storage: 'metal', +// base: { metal, crystal, deuterium, energyCost, +// energyConsumption, deuteriumConsumption, production }, +// } -Ogame.models.Destroyable[1]; -// → { name: 'chasseur léger', structure, shield, attack, cost: { metal, crystal, deut }, ... } +Ogame.models.Destroyable[11]; +// → { +// ogameId: 202, +// names: { en: 'Small Cargo', fr: 'Petit transporteur' }, +// structure: 4000, shield: 10, attack: 5, +// speed: 5000, cargo: 5000, fuelConsumption: 10, +// drive: 'combustion', +// driveUpgrades: [{ drive: 'impulse', minLevel: 5, speed: 10000, fuelConsumption: 20 }], +// type: 'civil', category: 'ships', +// rapidFire: [{ target: 15, fire: 5 }, ...], +// cost: { metal: 2000, crystal: 2000, deuterium: 0 }, +// } +``` + +#### Ids + +Ids 1–5 of `Buildings` and 1–302 of `Destroyable` are this library's own historical numbering. Every entry also carries **`ogameId`**, the id the game itself uses — that's the one to use when talking to OGame. + +```javascript +Ogame.models.Buildings[5].ogameId; // → 12 (Fusion Reactor) +Ogame.models.Destroyable[1].ogameId; // → 204 (Light Fighter) +Ogame.models.Destroyable[201].ogameId; // → 401 (Rocket Launcher) ``` +`rapidFire` targets are **library** ids, so they index straight into `Destroyable`. + +## TypeScript + +Declarations are generated from the JSDoc and shipped in `types/`. Nothing to install: + +```typescript +import Ogame from 'ogamejs'; +import BUILDINGS from 'ogamejs/models/buildings'; + +const cost = Ogame.Building.getBuildingCost(BUILDINGS[21], 12); +const total: number = cost.metal + cost.crystal + cost.deuterium; +``` + +The shared types are importable too, for typing your own helpers: + +```typescript +import type { BuildingEntry, FleetEntry, Coordinates } from 'ogamejs/types'; +``` + +## Known gaps + +Stated plainly, so you know what you are not getting: + +- **Lifeforms** (the 2021 expansion) are not modelled — neither their buildings nor their technologies. This is a data problem, not a code one: the tables run to roughly 120 entries and we would rather have them sourced than guessed. +- **Drive upgrade values** for the three ships that switch drive (small cargo, recycler, bomber) come from community tables rather than a first-party source. The speeds are solid; the post-switch fuel figures deserve an in-game check. +- **Defense repair** after a battle is not simulated, and neither are moon-creation odds. +- Officers other than the geologist, and alliance classes, are not modelled in the production bonuses. + ## Development ```bash @@ -206,9 +469,10 @@ npm install # install dependencies npm test # run the test suite (Vitest) npm run test:watch npm run lint # ESLint (flat config) +npm run types # type check and write types/ ``` -The library is written in native ESM and published straight from `src/` — there is no build step. +The library is written in native ESM and published straight from `src/` — the only build step is the declaration files, which `prepack` generates for you. ## Releases diff --git a/package-lock.json b/package-lock.json index e961c1e..fef6a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "git-cz": "^4.9.0", "globals": "^15.14.0", "semantic-release": "^25.0.8", + "typescript": "^7.0.2", "vitest": "^3.0.4" }, "engines": { @@ -2151,7 +2152,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2167,7 +2167,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2185,7 +2184,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2203,7 +2201,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2221,7 +2218,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2239,7 +2235,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2257,7 +2252,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2275,7 +2269,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2293,7 +2286,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2311,7 +2303,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2329,7 +2320,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2347,7 +2337,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2365,7 +2354,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2383,7 +2371,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2401,7 +2388,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2419,7 +2405,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2437,7 +2422,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2455,7 +2439,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2473,7 +2456,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -2491,7 +2473,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=16.20.0" } @@ -4744,6 +4725,7 @@ "version": "6.0.0", "dev": true, "license": "ISC", + "optional": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -9484,10 +9466,10 @@ }, "node_modules/typescript": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "bin": { "tsc": "bin/tsc" }, diff --git a/package.json b/package.json index ecf6699..fde1384 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,67 @@ { "name": "ogamejs", "version": "3.0.0", - "description": "", + "description": "OGame formulas as a dependency-free ESM library: building and research costs, production, build times, fleet debris and marketplace rates.", "type": "module", "main": "src/index.js", - "exports": "./src/index.js", + "sideEffects": false, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./src/index.js" + }, + "./buildings": { + "types": "./types/buildings/index.d.ts", + "default": "./src/buildings/index.js" + }, + "./fleets": { + "types": "./types/fleets/index.d.ts", + "default": "./src/fleets/index.js" + }, + "./research": { + "types": "./types/research/index.d.ts", + "default": "./src/research/index.js" + }, + "./trades": { + "types": "./types/trades/index.d.ts", + "default": "./src/trades/index.js" + }, + "./i18n": { + "types": "./types/i18n.d.ts", + "default": "./src/i18n.js" + }, + "./types": { + "types": "./types/types.d.ts", + "default": "./src/types.js" + }, + "./models/buildings": { + "types": "./types/models/buildings.d.ts", + "default": "./src/models/buildings.js" + }, + "./models/destroyable": { + "types": "./types/models/destroyable.d.ts", + "default": "./src/models/destroyable.js" + }, + "./models/research": { + "types": "./types/models/research.d.ts", + "default": "./src/models/research.js" + } + }, "engines": { "node": ">=24" }, "scripts": { "commit": "git-cz", "lint": "eslint .", + "types": "tsc", + "prepack": "npm run types", "test": "vitest run", "test:watch": "vitest", "release": "semantic-release" }, "files": [ "src", + "types", "!src/**/*.test.js" ], "repository": { @@ -41,11 +86,13 @@ "git-cz": "^4.9.0", "globals": "^15.14.0", "semantic-release": "^25.0.8", + "typescript": "^7.0.2", "vitest": "^3.0.4" }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } - } + }, + "types": "./types/index.d.ts" } diff --git a/src/buildings/buildTime.js b/src/buildings/buildTime.js new file mode 100644 index 0000000..ca88520 --- /dev/null +++ b/src/buildings/buildTime.js @@ -0,0 +1,25 @@ +import getCost, { assertLevel } from '../cost.js'; + +/** + * + * Return the time needed to build a building at a given level + * + * time = (metal + crystal) / (2500 * (1 + robotics) * 2 ** nanites) hours, + * divided by the universe economy speed. + * @param {import('../types.js').BuildingEntry} building A models/buildings.js entry + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} [roboticsLevel] The robotics factory level on that planet + * @param {number} [naniteLevel] The nanite factory level on that planet + * @param {number} [universeSpeed] The universe economy speed + * @returns {number} The build time, in seconds + */ +function getBuildTime(building, targetLevel, roboticsLevel = 0, naniteLevel = 0, universeSpeed = 1) { + assertLevel(targetLevel); + + const { metal, crystal } = getCost(building, targetLevel); + const divider = 2500 * (1 + roboticsLevel) * 2 ** naniteLevel * universeSpeed; + + return Math.round(((metal + crystal) / divider) * 3600); +} + +export default getBuildTime; diff --git a/src/buildings/buildTime.test.js b/src/buildings/buildTime.test.js new file mode 100644 index 0000000..c22a592 --- /dev/null +++ b/src/buildings/buildTime.test.js @@ -0,0 +1,21 @@ +import getBuildTime from './buildTime.js'; +import BUILDINGS from '../models/buildings.js'; + +describe('Build time should be correctly returned when', () => { + it('No robotics factory and no nanite factory are given', () => { + // (400 + 120) / 2500 hours + expect(getBuildTime(BUILDINGS[14], 1)).toBe(749); + }); + + it('A robotics factory halves the time at level 1', () => { + expect(getBuildTime(BUILDINGS[14], 1, 1)).toBe(374); + }); + + it('A nanite factory halves the time per level', () => { + expect(getBuildTime(BUILDINGS[14], 1, 0, 2)).toBe(187); + }); + + it('The universe economy speed is given', () => { + expect(getBuildTime(BUILDINGS[14], 1, 0, 0, 5)).toBe(150); + }); +}); diff --git a/src/buildings/crystal.js b/src/buildings/crystal.js index de1592f..4991e31 100644 --- a/src/buildings/crystal.js +++ b/src/buildings/crystal.js @@ -1,11 +1,10 @@ -function getPositionFactor(pos) { - const factor = { - 1: 1.3, - 2: 1.225, - 3: 1.15, - }; +import buildingInfo from './info.js'; + +// Positions 1, 2 and 3 give a 30%, 22.5% and 15% crystal production bonus. +const POSITION_FACTOR = { 1: 1.3, 2: 1.225, 3: 1.15 }; - return factor[Number.parseInt(pos, 10)] ? factor[Number.parseInt(pos, 10)] : 1; +function getPositionFactor(pos) { + return POSITION_FACTOR[Number.parseInt(pos, 10)] ?? 1; } function getMineProduction(baseProduction, targetLevel, pos, universeSpeed) { @@ -15,39 +14,22 @@ function getMineProduction(baseProduction, targetLevel, pos, universeSpeed) { return Math.floor(baseProduction * targetLevel * levelFactor * universeSpeed * positionFactor); } -function getEnergyCost(baseEnergyCost, targetLevel) { - const level = targetLevel; - const levelFactor = 1.1 ** level; - return Math.floor(baseEnergyCost * level * levelFactor); -} - -function getMetalCost(baseMetalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseMetalCost * 1.6 ** level); -} - -function getCrystalCost(baseCrystalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseCrystalCost * 1.6 ** level); -} - /** * - * Return information about the crystal mine given a specific level - * @param {object} mine The crystal mine base information - * @param {number} targetLevel the crystal mine target level - * @param {number} pos pos 1/2/3 have a 15/10/5% - * @param {number} universeSpeed production factor is increased for some universe - * @returns {Object} informations about the crystal mine at this specific level + * Return information about the crystal mine at a given level + * @param {import('../types.js').BuildingEntry} mine The crystal mine entry, `Buildings[2]` + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} pos The planet position, 1, 2 and 3 produce more crystal + * @param {number} [universeSpeed] Production is increased on faster universes + * @returns {import('../types.js').BuildingInfo} Cost, consumption and production at that level */ function getCrystalMine(mine, targetLevel, pos, universeSpeed = 1) { - return { - crystal: getCrystalCost(mine.crystal, targetLevel), - deuterium: 0, - energy: getEnergyCost(mine.energy, targetLevel), - metal: getMetalCost(mine.metal, targetLevel), - production: getMineProduction(mine.production, targetLevel, pos, universeSpeed), - }; + return buildingInfo( + mine, + targetLevel, + universeSpeed, + (base) => getMineProduction(base.production, targetLevel, pos, universeSpeed), + ); } export default getCrystalMine; diff --git a/src/buildings/crystal.test.js b/src/buildings/crystal.test.js index fb78b05..16b35b7 100644 --- a/src/buildings/crystal.test.js +++ b/src/buildings/crystal.test.js @@ -3,11 +3,13 @@ import BUILDINGS from '../models/buildings.js'; describe('Crystal mine informations should be correctly return when', () => { it('Level 30 is given and universe speed is 5 and position is 1', () => { - const mine = BUILDINGS[2].base; + const mine = BUILDINGS[2]; const crystalMine = getCrystalMine(mine, 30, 1, 5); expect(crystalMine).toEqual({ production: 68052, - energy: 5234, + energyCost: 0, + energyConsumption: 5234, + deuteriumConsumption: 0, metal: 39876839, crystal: 19938419, deuterium: 0, @@ -15,11 +17,13 @@ describe('Crystal mine informations should be correctly return when', () => { }); it('Level 30 is given and universe speed is 5 and position is 15', () => { - const mine = BUILDINGS[2].base; + const mine = BUILDINGS[2]; const crystalMine = getCrystalMine(mine, 30, 15, 5); expect(crystalMine).toEqual({ production: 52348, - energy: 5234, + energyCost: 0, + energyConsumption: 5234, + deuteriumConsumption: 0, metal: 39876839, crystal: 19938419, deuterium: 0, diff --git a/src/buildings/deut.js b/src/buildings/deut.js index 2884357..ec34481 100644 --- a/src/buildings/deut.js +++ b/src/buildings/deut.js @@ -1,46 +1,31 @@ -function getPositionFactor(avg) { - return 0.68 - 0.002 * avg; -} - -function getMineProduction(energyCost, avg, universeSpeed) { - const factor = getPositionFactor(avg); - return Math.floor(universeSpeed * energyCost * factor); -} +import buildingInfo from './info.js'; -function getEnergyCost(baseEnergyCost, targetLevel) { - const level = targetLevel; - const levelFactor = 1.1 ** level; - return Math.floor(baseEnergyCost * level * levelFactor); -} - -function getMetalCost(baseMetalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseMetalCost * 1.5 ** level); +// The colder the planet, the more deuterium the synthesizer draws out of it. +function getTemperatureFactor(avg) { + return 0.68 - 0.002 * avg; } -function getCrystalCost(baseCrystalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseCrystalCost * 1.5 ** level); +function getMineProduction(energyConsumption, avg, universeSpeed) { + return Math.floor(universeSpeed * energyConsumption * getTemperatureFactor(avg)); } /** * - * Return information about the deuterium synth given a specific level - * @param {object} mine The deut synth base information - * @param {number} targetLevel the deuterieum synth target level - * @param {number} avg planet average temperature - The lower the higher the prod is - * @param {number} universeSpeed production factor is increased for some universe - * @returns {Object} informations about the deut synth at this specific level + * Return information about the deuterium synthesizer at a given level + * @param {import('../types.js').BuildingEntry} mine The deuterium synthesizer entry, `Buildings[3]` + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} avg The planet average temperature, the lower the higher the production + * @param {number} [universeSpeed] Production is increased on faster universes + * @returns {import('../types.js').BuildingInfo} Cost, consumption and production at that level */ function getDeutSynth(mine, targetLevel, avg, universeSpeed = 1) { - const energyCost = getEnergyCost(mine.energy, targetLevel); - return { - crystal: getCrystalCost(mine.crystal, targetLevel), - energy: energyCost, - deuterium: 0, - metal: getMetalCost(mine.metal, targetLevel), - production: getMineProduction(energyCost, avg, universeSpeed), - }; + return buildingInfo( + mine, + targetLevel, + universeSpeed, + // The synthesizer production is driven by the energy it consumes. + (base, flows) => getMineProduction(flows.energyConsumption, avg, universeSpeed), + ); } export default getDeutSynth; diff --git a/src/buildings/deut.test.js b/src/buildings/deut.test.js index 46e5ae7..a44bdd4 100644 --- a/src/buildings/deut.test.js +++ b/src/buildings/deut.test.js @@ -3,11 +3,13 @@ import BUILDINGS from '../models/buildings.js'; describe('Deut mine informations should be correctly return when', () => { it('Level 30 is given and universe speed is 5 and average temperature is 37', () => { - const mine = BUILDINGS[3].base; + const mine = BUILDINGS[3]; const crystalMine = getDeutMine(mine, 30, 37, 5); expect(crystalMine).toEqual({ production: 31721, - energy: 10469, + energyCost: 0, + energyConsumption: 10469, + deuteriumConsumption: 0, metal: 28762658, crystal: 9587552, deuterium: 0, @@ -15,11 +17,13 @@ describe('Deut mine informations should be correctly return when', () => { }); it('Level 32 is given and universe speed is 5 and average temperature is 138', () => { - const mine = BUILDINGS[3].base; + const mine = BUILDINGS[3]; const crystalMine = getDeutMine(mine, 31, -138, 5); expect(crystalMine).toEqual({ production: 56882, - energy: 11900, + energyCost: 0, + energyConsumption: 11900, + deuteriumConsumption: 0, metal: 43143988, crystal: 14381329, deuterium: 0, diff --git a/src/buildings/fusion-reactor.js b/src/buildings/fusion-reactor.js index 1325117..339adb2 100644 --- a/src/buildings/fusion-reactor.js +++ b/src/buildings/fusion-reactor.js @@ -1,50 +1,35 @@ +import buildingInfo from './info.js'; + +// Energy technology makes each reactor level a little more efficient. function getEnergyFactor(energyTech) { return 1.05 + 0.01 * energyTech; } function getEnergyProduction(baseProduction, targetLevel, energyTech) { const factor = getEnergyFactor(energyTech) ** targetLevel; - return Math.floor(baseProduction * targetLevel * factor); -} -function getMetalCost(baseMetalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseMetalCost * 1.8 ** level); -} - -function getCrystalCost(baseCrystalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseCrystalCost * 1.8 ** level); -} - -function getDeuteriumCost(baseDeuteriumCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseDeuteriumCost * 1.8 ** level); -} - -function getConsumption(baseConsumption, targetLevel, universeSpeed) { - const levelFactor = 1.1 ** targetLevel; - return Math.floor(baseConsumption * targetLevel * levelFactor * universeSpeed); + return Math.floor(baseProduction * targetLevel * factor); } /** * - * Return information about the fusion reactor given a specific level - * @param {object} reactor The fusion react base information - * @param {number} targetLevel - * @param {number} energyTech The technology energy level - * @param {number} universeSpeed production factor is increased for some universe - * @returns {Object} informations about the fusion reactor at this specific level + * Return information about the fusion reactor at a given level + * + * `deuteriumConsumption` is the deuterium the reactor burns per hour, and + * `production` the energy it delivers. + * @param {import('../types.js').BuildingEntry} reactor The fusion reactor entry, `Buildings[5]` + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} energyTech The Energy Technology level + * @param {number} [universeSpeed] Consumption is increased on faster universes + * @returns {import('../types.js').BuildingInfo} Cost, deuterium consumption and energy production */ function getFusionReactor(reactor, targetLevel, energyTech, universeSpeed = 1) { - return { - crystal: getCrystalCost(reactor.crystal, targetLevel), - energy: 0, - consumption: getConsumption(reactor.consumption, targetLevel, universeSpeed), - deuterium: getDeuteriumCost(reactor.deutrium, targetLevel), - metal: getMetalCost(reactor.metal, targetLevel), - production: getEnergyProduction(reactor.production, targetLevel, energyTech), - }; + return buildingInfo( + reactor, + targetLevel, + universeSpeed, + (base) => getEnergyProduction(base.production, targetLevel, energyTech), + ); } export default getFusionReactor; diff --git a/src/buildings/fusion-reactor.test.js b/src/buildings/fusion-reactor.test.js index ab7e454..5db6c5f 100644 --- a/src/buildings/fusion-reactor.test.js +++ b/src/buildings/fusion-reactor.test.js @@ -3,15 +3,16 @@ import BUILDINGS from '../models/buildings.js'; describe('Fusion reactor informations should be correctly return when', () => { it('Level 19 is given with ernergy tech 17', () => { - const reactor = BUILDINGS[5].base; + const reactor = BUILDINGS[5]; const fusionReact = getFusionReactor(reactor, 19, 17, 5); expect(fusionReact).toEqual({ production: 24929, - energy: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 5810, metal: 35411767, crystal: 14164706, deuterium: 7082353, - consumption: 5810, }); }); }); diff --git a/src/buildings/index.js b/src/buildings/index.js index 164877e..ef6e623 100644 --- a/src/buildings/index.js +++ b/src/buildings/index.js @@ -4,6 +4,10 @@ import getMetalMine from './metal.js'; import getSolarPlant from './solar-plant.js'; import getFusionReactor from './fusion-reactor.js'; import parseInfoCompteData from './infocompte.js'; +import getCost from '../cost.js'; +import getStorage, { getStorageCapacity, getStorageLevelFor } from './storage.js'; +import getBuildTime from './buildTime.js'; +import getPlanetProduction, { getProductionBonus } from './production.js'; const Buildings = { getCrystalMine, @@ -12,6 +16,13 @@ const Buildings = { getSolarPlant, getFusionReactor, parseInfoCompteData, + getBuildingCost: getCost, + getBuildTime, + getStorage, + getStorageCapacity, + getStorageLevelFor, + getPlanetProduction, + getProductionBonus, }; export default Buildings; diff --git a/src/buildings/info.js b/src/buildings/info.js new file mode 100644 index 0000000..8b50d6a --- /dev/null +++ b/src/buildings/info.js @@ -0,0 +1,42 @@ +import getCost, { assertEntry } from '../cost.js'; + +/** + * Energy and deuterium flows both grow the same way: base * level * 1.1 ** level. + */ +function flow(baseValue, targetLevel, universeSpeed = 1) { + return Math.floor(baseValue * targetLevel * 1.1 ** targetLevel * universeSpeed); +} + +/** + * + * Assemble the information every building calculator returns + * + * Keeping a single shape means a caller can read `production` or + * `energyConsumption` without knowing which building it is looking at; the + * fields that do not apply are simply `0`. + * @param {import('../types.js').BuildingEntry} entry A models/buildings.js entry + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} universeSpeed The universe economy speed + * @param {(base: object, flows: object) => number} computeProduction Production of + * that building, given its base stats and its already computed consumptions + * @returns {import('../types.js').BuildingInfo} The full picture of that building at that level + */ +function buildingInfo(entry, targetLevel, universeSpeed, computeProduction) { + assertEntry(entry, targetLevel); + + const { base } = entry; + const flows = { + // The energy a building consumes does not depend on the universe speed, + // the deuterium a fusion reactor burns does. + energyConsumption: flow(base.energyConsumption, targetLevel), + deuteriumConsumption: flow(base.deuteriumConsumption, targetLevel, universeSpeed), + }; + + return { + ...getCost(entry, targetLevel), + ...flows, + production: computeProduction(base, flows), + }; +} + +export default buildingInfo; diff --git a/src/buildings/infocompte.js b/src/buildings/infocompte.js index 9f8ce61..c5a04f9 100644 --- a/src/buildings/infocompte.js +++ b/src/buildings/infocompte.js @@ -1,64 +1,137 @@ -function toNumber(number = '') { - return Number(number.split('.').join('')); -} - /** + * Labels used by the Infocompte export, per game language. * - * Return information about the crystal mine given a specific level - * @param {object} data The infocompte bb-code - * @returns {Object} The parsed JSON object of infocompte + * The French set is the reference one; the others are best effort. A locale can + * be overridden per call through the `labels` option, so an unsupported + * language does not require a release. */ -function parseInfoCompteData(data) { - const universeDataRe = new RegExp('([0-9]{3}-[a-z]{2})'); - const [universe, lang] = data.match(universeDataRe)[1].split('-'); +const LOCALES = Object.freeze({ + fr: { + planet: 'Planète', + metalPoints: 'Points dans les mines de métal', + crystalPoints: 'Points dans les mines de cristal', + deutPoints: 'Points dans les mines de deut', + plasma: 'Technologie Plasma', + hourly: 'Par heure', + }, + en: { + planet: 'Planet', + metalPoints: 'Points in metal mines', + crystalPoints: 'Points in crystal mines', + deutPoints: 'Points in deut mines', + plasma: 'Plasma Technology', + hourly: 'Per hour', + }, +}); - // eslint-disable-next-line security/detect-unsafe-regex - const planetsRe = new RegExp('(?:Planète [0-9]+(.+))(?:s+(?:Planète [0-9]+))*', 'g'); - const planets = data.matchAll(planetsRe); +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} - const metalPointRe = new RegExp('Points dans les mines de métal : (.+)'); - const metalPoint = toNumber(data.match(metalPointRe)[1]); +// Infocompte groups thousands with dots, spaces or non breaking spaces. +function toNumber(value = '') { + return Number(String(value).replace(/[^\d-]/g, '')); +} - const crystalPointRe = new RegExp('Points dans les mines de cristal : (.+)'); - const crystalPoint = toNumber(data.match(crystalPointRe)[1]); +function matchOne(data, label, what) { + // The label is escaped just above, so it cannot inject a pattern. + // eslint-disable-next-line security/detect-non-literal-regexp + const match = data.match(new RegExp(`${escapeRegExp(label)}\\s*:\\s*(.+)`)); - const deutRe = new RegExp('Points dans les mines de deut : (.+)'); - const deutPoint = toNumber(data.match(deutRe)[1]); + if (!match) { + throw new Error(`could not find ${what} in the report, is the language supported?`); + } - const plasmaRe = new RegExp('Technologie Plasma : (.+)'); - const plasmaTechLevel = toNumber(data.match(plasmaRe)[1]); + return match[1]; +} - const hourlyRe = new RegExp('Par heure : (.+)'); - const hourly = data.match(hourlyRe); +function parseTriplet(line, what) { + // Digits, plus the dot, space, non breaking space and narrow no-break space + // Infocompte may use as a thousands separator. + const numbers = line.match(/\d[\d.\u0020\u00A0\u202F]*/g); - const [hourlyMetal, hourlyCristal, hourlyDeut] = hourly[1].split('/'); + if (!numbers || numbers.length < 3) { + throw new Error(`could not parse ${what} in the report`); + } - const [hourlyMetalValue] = hourlyMetal.split(' '); - const hourlyMetalNumber = toNumber(hourlyMetalValue); + return numbers.slice(0, 3).map(toNumber); +} - const [hourlyCristalValue] = hourlyCristal.split(' ').filter(Boolean); - const hourlyCristalNumber = toNumber(hourlyCristalValue); +function parsePlanets(data, labels) { + // Matches `Planète 01 : Métal 36 / Cristal 31 / Deutérium 31 / -94°C` without + // depending on the resource names, only on the shape of the line. + // eslint-disable-next-line security/detect-non-literal-regexp + const planetRe = new RegExp( + `^[^\\S\\n]*(${escapeRegExp(labels.planet)}\\s+\\d+)\\s*:` + + '[^\\d]*(\\d+)\\s*/[^\\d-]*(\\d+)\\s*/[^\\d-]*(\\d+)\\s*/\\s*(-?\\d+)\\s*°', + 'gm', + ); - const [hourlyDeutValue] = hourlyDeut.split(' ').filter(Boolean); - const hourlyDeutNumber = toNumber(hourlyDeutValue); + return [...data.matchAll(planetRe)].map(([, planet, metal, crystal, deut, temperature]) => ({ + planet: planet.trim(), + metal: Number(metal), + crystal: Number(crystal), + deut: Number(deut), + temperature: Number(temperature), + })); +} - const response = { - planets: [], +/** + * + * Parse the BBCode of an Infocompte report into a structured object + * @param {string} data The infocompte bb-code + * @param {object} [options] Parsing options + * @param {string} [options.locale] Force a locale instead of reading it from the report + * @param {object} [options.labels] Override the labels, see LOCALES for the shape + * @returns {Object} The parsed JSON object of infocompte + */ +function parseInfoCompteData(data, options = {}) { + if (typeof data !== 'string' || data.trim() === '') { + throw new Error('data must be a non empty string'); + } + + const universeData = data.match(/([0-9]{3}-[a-z]{2})/); + + if (!universeData) { + throw new Error('could not find the universe and language header in the report'); + } + + const [universe, lang] = universeData[1].split('-'); + const locale = options.locale ?? lang; + // eslint-disable-next-line security/detect-object-injection + const knownLocale = Object.hasOwn(LOCALES, locale) ? LOCALES[locale] : undefined; + const labels = options.labels ?? knownLocale ?? LOCALES.fr; + + const metalPoint = toNumber(matchOne(data, labels.metalPoints, 'the metal mine points')); + const crystalPoint = toNumber(matchOne(data, labels.crystalPoints, 'the crystal mine points')); + const deutPoint = toNumber(matchOne(data, labels.deutPoints, 'the deuterium mine points')); + const plasmaTechLevel = toNumber(matchOne(data, labels.plasma, 'the plasma technology level')); + + const [hourlyMetal, hourlyCrystal, hourlyDeut] = parseTriplet( + matchOne(data, labels.hourly, 'the hourly production'), + 'the hourly production', + ); + + const perDay = 24; + const perWeek = 24 * 7; + + return { + planets: parsePlanets(data, labels), production: { hourly: { - metal: hourlyMetalNumber, - crystal: hourlyCristalNumber, - deut: hourlyDeutNumber, + metal: hourlyMetal, + crystal: hourlyCrystal, + deut: hourlyDeut, }, daily: { - metal: hourlyMetalNumber * 24, - crystal: hourlyCristalNumber * 24, - deut: hourlyDeutNumber * 24, + metal: hourlyMetal * perDay, + crystal: hourlyCrystal * perDay, + deut: hourlyDeut * perDay, }, weekly: { - metal: hourlyMetalNumber * 24 * 7, - crystal: hourlyCristalNumber * 24 * 7, - deut: hourlyDeutNumber * 24 * 7, + metal: hourlyMetal * perWeek, + crystal: hourlyCrystal * perWeek, + deut: hourlyDeut * perWeek, }, }, points: { @@ -71,21 +144,7 @@ function parseInfoCompteData(data) { universe: Number(universe), lang, }; - - for (const planet of planets) { - const [planetMetal, crystal, deut, temperatureText] = planet[0].split('/'); - const [temperature] = temperatureText.split('°'); - const [thePlanet, metal] = planetMetal.split(':'); - - response.planets.push({ - planet: thePlanet.trim(), - metal: metal.trim().split(' ')[1], - crystal: crystal.trim().split(' ')[1], - deut: deut.trim().split(' ')[1], - temperature: Number.parseInt(temperature.trim(), 10), - }); - } - - return response; } + +export { LOCALES }; export default parseInfoCompteData; diff --git a/src/buildings/infocompte.test.js b/src/buildings/infocompte.test.js index 3865967..d816cc9 100644 --- a/src/buildings/infocompte.test.js +++ b/src/buildings/infocompte.test.js @@ -1,8 +1,6 @@ import parseInfoCompteData from './infocompte.js'; -describe('Infocompte informations should be correctly return when', () => { - it('Version is ', () => { - const params = ` +const frenchReport = ` Niveau des mines du joueur Rolljee ( s165-fr ) le 07/04/2020, 16:40:32 : Planète 01 : Métal 36 / Cristal 31 / Deutérium 31 / -94°C @@ -31,41 +29,62 @@ describe('Infocompte informations should be correctly return when', () => { Export with Infocompte v7.0.10 `; - const response = parseInfoCompteData(params); + +const englishReport = ` + Mine levels of player Rolljee ( s165-en ) on 07/04/2020, 16:40:32 : + + Planet 01 : Metal 36 / Crystal 31 / Deuterium 31 / -94°C + Planet 02 : Metal 20 / Crystal 18 / Deuterium 15 / 56°C + + Points in metal mines : 4.832.457 + Points in crystal mines : 2.807.321 + Points in deut mines : 3.343.660 + Plasma Technology : 15 + + Par heure : 2.292.692 Metal / 726.693 Crystal / 718.186 Deuterium + + Per hour : 2.292.692 Metal / 726.693 Crystal / 718.186 Deuterium + + Export with Infocompte v7.0.10 + `; + +describe('Infocompte informations should be correctly return when', () => { + it('A French report is given', () => { + const response = parseInfoCompteData(frenchReport); expect(response).toEqual({ planets: [ { - planet: 'Planète 01', metal: '36', crystal: '31', deut: '31', temperature: -94, + planet: 'Planète 01', metal: 36, crystal: 31, deut: 31, temperature: -94, }, { - planet: 'Planète 02', metal: '36', crystal: '31', deut: '31', temperature: -95, + planet: 'Planète 02', metal: 36, crystal: 31, deut: 31, temperature: -95, }, { - planet: 'Planète 03', metal: '36', crystal: '31', deut: '31', temperature: 56, + planet: 'Planète 03', metal: 36, crystal: 31, deut: 31, temperature: 56, }, { - planet: 'Planète 04', metal: '36', crystal: '31', deut: '31', temperature: -118, + planet: 'Planète 04', metal: 36, crystal: 31, deut: 31, temperature: -118, }, { - planet: 'Planète 05', metal: '36', crystal: '31', deut: '31', temperature: -122, + planet: 'Planète 05', metal: 36, crystal: 31, deut: 31, temperature: -122, }, { - planet: 'Planète 06', metal: '36', crystal: '31', deut: '31', temperature: -97, + planet: 'Planète 06', metal: 36, crystal: 31, deut: 31, temperature: -97, }, { - planet: 'Planète 07', metal: '36', crystal: '31', deut: '31', temperature: -96, + planet: 'Planète 07', metal: 36, crystal: 31, deut: 31, temperature: -96, }, { - planet: 'Planète 08', metal: '36', crystal: '31', deut: '33', temperature: -108, + planet: 'Planète 08', metal: 36, crystal: 31, deut: 33, temperature: -108, }, { - planet: 'Planète 09', metal: '38', crystal: '31', deut: '34', temperature: -103, + planet: 'Planète 09', metal: 38, crystal: 31, deut: 34, temperature: -103, }, { - planet: 'Planète 10', metal: '38', crystal: '31', deut: '34', temperature: -76, + planet: 'Planète 10', metal: 38, crystal: 31, deut: 34, temperature: -76, }, { - planet: 'Planète 11', metal: '38', crystal: '31', deut: '34', temperature: -116, + planet: 'Planète 11', metal: 38, crystal: 31, deut: 34, temperature: -116, }, ], production: { @@ -81,4 +100,58 @@ describe('Infocompte informations should be correctly return when', () => { lang: 'fr', }); }); + + it('An English report is given', () => { + const response = parseInfoCompteData(englishReport); + + expect(response.lang).toBe('en'); + expect(response.universe).toBe(165); + expect(response.plasma).toBe(15); + expect(response.planets).toEqual([ + { + planet: 'Planet 01', metal: 36, crystal: 31, deut: 31, temperature: -94, + }, + { + planet: 'Planet 02', metal: 20, crystal: 18, deut: 15, temperature: 56, + }, + ]); + expect(response.production.hourly).toEqual({ + metal: 2292692, crystal: 726693, deut: 718186, + }); + expect(response.points.total).toBe(10983438); + }); + + it('A locale is forced through the options', () => { + const response = parseInfoCompteData(frenchReport, { locale: 'fr' }); + expect(response.planets).toHaveLength(11); + }); + + it('Custom labels are given for an unsupported language', () => { + const labels = { + planet: 'Planet', + metalPoints: 'Points in metal mines', + crystalPoints: 'Points in crystal mines', + deutPoints: 'Points in deut mines', + plasma: 'Plasma Technology', + hourly: 'Per hour', + }; + const response = parseInfoCompteData(englishReport.replace('s165-en', '165-xx'), { labels }); + expect(response.planets).toHaveLength(2); + }); +}); + +describe('Infocompte parsing should throw when', () => { + it('No data is given', () => { + expect(() => parseInfoCompteData('')).toThrow('data must be a non empty string'); + }); + + it('The universe header is missing', () => { + expect(() => parseInfoCompteData('Technologie Plasma : 15')) + .toThrow('could not find the universe and language header in the report'); + }); + + it('A section is missing', () => { + const truncated = frenchReport.replace('Technologie Plasma : 15', ''); + expect(() => parseInfoCompteData(truncated)).toThrow('the plasma technology level'); + }); }); diff --git a/src/buildings/metal.js b/src/buildings/metal.js index d4dc37c..3d473a8 100644 --- a/src/buildings/metal.js +++ b/src/buildings/metal.js @@ -1,41 +1,26 @@ +import buildingInfo from './info.js'; + function getMineProduction(baseProduction, targetLevel, universeSpeed) { const levelFactor = 1.1 ** targetLevel; return Math.floor(baseProduction * targetLevel * levelFactor * universeSpeed); } -function getEnergyCost(baseEnergyCost, targetLevel) { - const level = targetLevel; - const levelFactor = 1.1 ** level; - return Math.floor(baseEnergyCost * level * levelFactor); -} - -function getMetalCost(baseMetalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseMetalCost * 1.5 ** level); -} - -function getCrystalCost(baseCrystalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseCrystalCost * 1.5 ** level); -} - /** * - * Return information about the metal mine given a specific level - * @param {object} mine The metal mine base information - * @param {number} targetLevel - * @param {number} universeSpeed production factor is increased for some universe - * @returns {Object} informations about the metal mine at this specific level + * Return information about the metal mine at a given level + * @param {import('../types.js').BuildingEntry} mine The metal mine entry, `Buildings[1]` + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} [universeSpeed] Production is increased on faster universes + * @returns {import('../types.js').BuildingInfo} Cost, consumption and production at that level */ function getMetalMine(mine, targetLevel, universeSpeed = 1) { - return { - crystal: getCrystalCost(mine.crystal, targetLevel), - deuterium: 0, - energy: getEnergyCost(mine.energy, targetLevel), - metal: getMetalCost(mine.metal, targetLevel), - production: getMineProduction(mine.production, targetLevel, universeSpeed), - }; + return buildingInfo( + mine, + targetLevel, + universeSpeed, + (base) => getMineProduction(base.production, targetLevel, universeSpeed), + ); } export default getMetalMine; diff --git a/src/buildings/metal.test.js b/src/buildings/metal.test.js index e560f20..93b9cf1 100644 --- a/src/buildings/metal.test.js +++ b/src/buildings/metal.test.js @@ -3,11 +3,13 @@ import BUILDINGS from '../models/buildings.js'; describe('Metal mine informations should be correctly return when', () => { it('Level 10 is given and universe speed is 5', () => { - const mine = BUILDINGS[1].base; + const mine = BUILDINGS[1]; const metalMine = getMetalMine(mine, 10, 5); expect(metalMine).toEqual({ production: 3890, - energy: 259, + energyCost: 0, + energyConsumption: 259, + deuteriumConsumption: 0, metal: 2306, crystal: 576, deuterium: 0, @@ -15,11 +17,13 @@ describe('Metal mine informations should be correctly return when', () => { }); it('Level 36 is given and universe speed is 5', () => { - const mine = BUILDINGS[1].base; + const mine = BUILDINGS[1]; const metalMine = getMetalMine(mine, 36, 5); expect(metalMine).toEqual({ production: 166928, - energy: 11128, + energyCost: 0, + energyConsumption: 11128, + deuteriumConsumption: 0, metal: 87366576, crystal: 21841644, deuterium: 0, diff --git a/src/buildings/production.js b/src/buildings/production.js new file mode 100644 index 0000000..b1f2ed5 --- /dev/null +++ b/src/buildings/production.js @@ -0,0 +1,150 @@ +import getMetalMine from './metal.js'; +import getCrystalMine from './crystal.js'; +import getDeutSynth from './deut.js'; +import BUILDINGS from '../models/buildings.js'; + +/** + * Plasma technology is worth more on metal than on crystal, and more on crystal + * than on deuterium. + */ +const PLASMA_BONUS = Object.freeze({ metal: 0.01, crystal: 0.0066, deuterium: 0.0033 }); + +/** The geologist officer adds a flat 10% to the three mines. */ +const GEOLOGIST_BONUS = 0.1; + +/** The collector class adds 25% to mine production. */ +const COLLECTOR_BONUS = 0.25; + +/** + * Every planet produces this much per hour on its own, mines or not. It is a + * flat income: no bonus applies to it. + */ +const BASE_INCOME = Object.freeze({ metal: 30, crystal: 15, deuterium: 0 }); + +const RESOURCES = Object.freeze(['metal', 'crystal', 'deuterium']); + +function clampRatio(value, name) { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error(`${name} must be a number between 0 and 1, received ${value}`); + } + + return value; +} + +/** + * + * Return the production multiplier of each resource + * + * The multipliers add up, they are not compounded: plasma 15, a geologist and + * the collector class give 1 + 0.15 + 0.1 + 0.25 on metal. + * @param {object} [options] The bonuses that apply + * @param {number} [options.plasmaTech] The Plasma Technology level + * @param {boolean} [options.geologist] Whether the geologist officer is hired + * @param {boolean} [options.collectorClass] Whether the player plays collector + * @param {object} [options.items] Booster items in place, as fractions per + * resource — a 30% metal booster is `{ metal: 0.3 }` + * @returns {import('../types.js').Resources} One multiplier per resource + */ +function getProductionBonus(options = {}) { + const { + plasmaTech = 0, + geologist = false, + collectorClass = false, + items = {}, + } = options; + + if (!Number.isInteger(plasmaTech) || plasmaTech < 0) { + throw new Error(`plasmaTech must be an integer >= 0, received ${plasmaTech}`); + } + + const flat = (geologist ? GEOLOGIST_BONUS : 0) + (collectorClass ? COLLECTOR_BONUS : 0); + + return Object.fromEntries(RESOURCES.map((resource) => { + /* eslint-disable security/detect-object-injection -- resource comes from RESOURCES */ + const item = clampRatio(items[resource] ?? 0, `items.${resource}`); + + return [resource, 1 + PLASMA_BONUS[resource] * plasmaTech + flat + item]; + /* eslint-enable security/detect-object-injection */ + })); +} + +/** + * + * Return the hourly production of a whole planet, bonuses included + * + * Mine levels, planet position and temperature give the raw mine output; the + * plasma, officer, class and item bonuses are then applied, and the flat planet + * income is added on top. + * @param {object} planet The planet description + * @param {number} planet.metalMine The metal mine level + * @param {number} planet.crystalMine The crystal mine level + * @param {number} planet.deutSynth The deuterium synthesizer level + * @param {number} planet.position The planet position, 1 to 3 produce more crystal + * @param {number} planet.temperature The planet average temperature + * @param {number} [planet.universeSpeed] The universe economy speed + * @param {number} [planet.energyEfficiency] The share of the required energy that + * is actually available, between 0 and 1 — mines run at that rate when the + * planet is in an energy deficit + * @param {object} [bonuses] Passed straight to `getProductionBonus` + * @returns {import('../types.js').Resources & {energyConsumption: number, bonus: import('../types.js').Resources}} + * The hourly production, what it costs in energy, and the multipliers used + */ +function getPlanetProduction(planet, bonuses = {}) { + const { + metalMine, + crystalMine, + deutSynth, + position, + temperature, + universeSpeed = 1, + energyEfficiency = 1, + } = planet; + + clampRatio(energyEfficiency, 'energyEfficiency'); + + for (const [name, level] of Object.entries({ metalMine, crystalMine, deutSynth })) { + if (!Number.isInteger(level) || level < 0) { + throw new Error(`${name} must be an integer >= 0, received ${level}`); + } + } + + // A mine that is not built yet neither produces nor consumes anything, and + // has no level 0 cost to speak of. + const idle = { production: 0, energyConsumption: 0 }; + const mines = { + metal: metalMine > 0 ? getMetalMine(BUILDINGS[1], metalMine, universeSpeed) : idle, + crystal: crystalMine > 0 + ? getCrystalMine(BUILDINGS[2], crystalMine, position, universeSpeed) + : idle, + deuterium: deutSynth > 0 + ? getDeutSynth(BUILDINGS[3], deutSynth, temperature, universeSpeed) + : idle, + }; + + const bonus = getProductionBonus(bonuses); + + const production = Object.fromEntries(RESOURCES.map((resource) => { + /* eslint-disable security/detect-object-injection -- resource comes from RESOURCES */ + const mined = mines[resource].production * bonus[resource] * energyEfficiency; + + return [resource, Math.floor(mined) + BASE_INCOME[resource] * universeSpeed]; + /* eslint-enable security/detect-object-injection */ + })); + + return { + ...production, + energyConsumption: RESOURCES + // eslint-disable-next-line security/detect-object-injection + .reduce((total, resource) => total + mines[resource].energyConsumption, 0), + bonus, + }; +} + +export { + getProductionBonus, + PLASMA_BONUS, + GEOLOGIST_BONUS, + COLLECTOR_BONUS, + BASE_INCOME, +}; +export default getPlanetProduction; diff --git a/src/buildings/production.test.js b/src/buildings/production.test.js new file mode 100644 index 0000000..065bc13 --- /dev/null +++ b/src/buildings/production.test.js @@ -0,0 +1,111 @@ +import getPlanetProduction, { + getProductionBonus, BASE_INCOME, +} from './production.js'; + +describe('Production bonuses should be correctly returned when', () => { + it('Nothing applies', () => { + expect(getProductionBonus()).toEqual({ metal: 1, crystal: 1, deuterium: 1 }); + }); + + it('Plasma technology is given', () => { + // 1% metal, 0.66% crystal and 0.33% deuterium per level. + const bonus = getProductionBonus({ plasmaTech: 15 }); + expect(bonus.metal).toBeCloseTo(1.15); + expect(bonus.crystal).toBeCloseTo(1.099); + expect(bonus.deuterium).toBeCloseTo(1.0495); + }); + + it('The geologist and the collector class are given', () => { + expect(getProductionBonus({ geologist: true, collectorClass: true })) + .toEqual({ metal: 1.35, crystal: 1.35, deuterium: 1.35 }); + }); + + it('Booster items are given per resource', () => { + const bonus = getProductionBonus({ items: { metal: 0.4, deuterium: 0.1 } }); + expect(bonus.metal).toBeCloseTo(1.4); + expect(bonus.crystal).toBeCloseTo(1); + expect(bonus.deuterium).toBeCloseTo(1.1); + }); + + it('Everything applies at once, adding up rather than compounding', () => { + const bonus = getProductionBonus({ + plasmaTech: 10, geologist: true, collectorClass: true, items: { metal: 0.3 }, + }); + // 1 + 0.10 + 0.1 + 0.25 + 0.3 + expect(bonus.metal).toBeCloseTo(1.75); + }); +}); + +describe('Production bonuses should throw when', () => { + it('The plasma level is not a positive integer', () => { + expect(() => getProductionBonus({ plasmaTech: -1 })) + .toThrow('plasmaTech must be an integer >= 0'); + }); + + it('An item bonus is out of range', () => { + expect(() => getProductionBonus({ items: { metal: 2 } })) + .toThrow('items.metal must be a number between 0 and 1'); + }); +}); + +describe('Planet production should be correctly returned when', () => { + const planet = { + metalMine: 30, crystalMine: 25, deutSynth: 22, position: 8, temperature: -20, + }; + + it('No bonus applies', () => { + const production = getPlanetProduction(planet); + + // The flat planet income is added on top and no bonus applies to it. + expect(production.metal).toBe(15704 + BASE_INCOME.metal); + expect(production.crystal).toBe(5417 + BASE_INCOME.crystal); + expect(production.deuterium).toBe(2578 + BASE_INCOME.deuterium); + }); + + it('The universe is faster', () => { + const slow = getPlanetProduction(planet); + const fast = getPlanetProduction({ ...planet, universeSpeed: 5 }); + + // Mines scale with the universe speed, and so does the flat income. The + // mine output is floored once at the end, so this is not exactly five times + // the slow figure. + expect(fast.metal).toBe(78672); + expect(fast.metal).toBeCloseTo(slow.metal * 5, -1); + }); + + it('Bonuses apply', () => { + const plain = getPlanetProduction(planet); + const boosted = getPlanetProduction(planet, { plasmaTech: 15, geologist: true }); + + expect(boosted.metal).toBeGreaterThan(plain.metal); + expect(boosted.bonus.metal).toBeCloseTo(1.25); + }); + + it('A mine is not built yet', () => { + const production = getPlanetProduction({ ...planet, deutSynth: 0 }); + + expect(production.deuterium).toBe(BASE_INCOME.deuterium); + }); + + it('The planet is short on energy', () => { + const full = getPlanetProduction(planet); + const half = getPlanetProduction({ ...planet, energyEfficiency: 0.5 }); + + expect(half.metal - BASE_INCOME.metal) + .toBe(Math.floor((full.metal - BASE_INCOME.metal) / 2)); + }); + + it('The energy the mines need is reported', () => { + expect(getPlanetProduction(planet).energyConsumption).toBeGreaterThan(0); + }); + + it('A mine level is negative', () => { + expect(() => getPlanetProduction({ ...planet, crystalMine: -1 })) + .toThrow('crystalMine must be an integer >= 0'); + }); + + it('The energy efficiency is out of range', () => { + expect(() => getPlanetProduction({ ...planet, energyEfficiency: 1.5 })) + .toThrow('energyEfficiency must be a number between 0 and 1'); + }); +}); diff --git a/src/buildings/solar-plant.js b/src/buildings/solar-plant.js index 38c24c1..245f9a8 100644 --- a/src/buildings/solar-plant.js +++ b/src/buildings/solar-plant.js @@ -1,33 +1,25 @@ +import buildingInfo from './info.js'; + function getEnergyProduction(baseProduction, targetLevel) { const levelFactor = 1.1 ** targetLevel; - return Math.floor(baseProduction * targetLevel * levelFactor); -} -function getMetalCost(baseMetalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseMetalCost * 1.5 ** level); -} - -function getCrystalCost(baseCrystalCost, targetLevel) { - const level = targetLevel - 1; - return Math.floor(baseCrystalCost * 1.5 ** level); + return Math.floor(baseProduction * targetLevel * levelFactor); } /** * - * Return information about the solar plant given a specific level - * @param {object} solarPlant The solarPlant base information - * @param {number} targetLevel - * @returns {Object} informations about the solar plant at this specific level + * Return information about the solar plant at a given level + * @param {import('../types.js').BuildingEntry} solarPlant The solar plant entry, `Buildings[4]` + * @param {number} targetLevel The level to reach, >= 1 + * @returns {import('../types.js').BuildingInfo} Cost of the plant, and the energy it produces */ function getSolarPlant(solarPlant, targetLevel) { - return { - crystal: getCrystalCost(solarPlant.crystal, targetLevel), - energy: 0, - deuterium: 0, - metal: getMetalCost(solarPlant.metal, targetLevel), - production: getEnergyProduction(solarPlant.production, targetLevel), - }; + return buildingInfo( + solarPlant, + targetLevel, + 1, + (base) => getEnergyProduction(base.production, targetLevel), + ); } export default getSolarPlant; diff --git a/src/buildings/solar-plant.test.js b/src/buildings/solar-plant.test.js index 372d963..d3d449d 100644 --- a/src/buildings/solar-plant.test.js +++ b/src/buildings/solar-plant.test.js @@ -3,11 +3,13 @@ import BUILDINGS from '../models/buildings.js'; describe('Solar plant informations should be correctly return when', () => { it('Level 25 is given', () => { - const mine = BUILDINGS[4].base; + const mine = BUILDINGS[4]; const solarPlant = getSolarPlant(mine, 25); expect(solarPlant).toEqual({ production: 5417, - energy: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, metal: 1262558, crystal: 505023, deuterium: 0, diff --git a/src/buildings/storage.js b/src/buildings/storage.js new file mode 100644 index 0000000..536ae28 --- /dev/null +++ b/src/buildings/storage.js @@ -0,0 +1,59 @@ +import getCost, { assertLevel } from '../cost.js'; + +/** + * + * Return the capacity of a storage building at a given level + * + * Level 0 is the free 10.000 units every planet starts with. + * @param {number} level The storage level, >= 0 + * @returns {number} The protected capacity, in resource units + */ +function getStorageCapacity(level) { + if (!Number.isInteger(level) || level < 0) { + throw new Error(`level must be an integer >= 0, received ${level}`); + } + + return 5000 * Math.floor(2.5 * Math.exp((20 * level) / 33)); +} + +/** + * + * Return the cost and the capacity of a storage building at a given level + * @param {import('../types.js').BuildingEntry} storage A storage entry of models/buildings.js (22, 23 or 24) + * @param {number} targetLevel The level to reach, >= 1 + * @returns {import('../types.js').Cost & {capacity: number}} The cost of that level plus the capacity + */ +function getStorage(storage, targetLevel) { + assertLevel(targetLevel); + + if (!storage || !storage.storage) { + throw new Error('storage must be a storage building entry (Buildings 22, 23 or 24)'); + } + + return { + ...getCost(storage, targetLevel), + capacity: getStorageCapacity(targetLevel), + }; +} + +/** + * + * Return the lowest storage level able to protect a given amount of resources + * @param {number} amount The amount of resources to protect + * @returns {number} The required storage level + */ +function getStorageLevelFor(amount) { + if (!Number.isFinite(amount) || amount < 0) { + throw new Error(`amount must be a positive number, received ${amount}`); + } + + let level = 0; + while (getStorageCapacity(level) < amount) { + level += 1; + } + + return level; +} + +export { getStorageCapacity, getStorageLevelFor }; +export default getStorage; diff --git a/src/buildings/storage.test.js b/src/buildings/storage.test.js new file mode 100644 index 0000000..5446162 --- /dev/null +++ b/src/buildings/storage.test.js @@ -0,0 +1,56 @@ +import getStorage, { getStorageCapacity, getStorageLevelFor } from './storage.js'; +import BUILDINGS from '../models/buildings.js'; + +describe('Storage capacity should be correctly returned when', () => { + it('Level 0 is given, which is the free capacity of every planet', () => { + expect(getStorageCapacity(0)).toBe(10000); + }); + + it('The first levels are given', () => { + expect(getStorageCapacity(1)).toBe(20000); + expect(getStorageCapacity(2)).toBe(40000); + expect(getStorageCapacity(3)).toBe(75000); + }); + + it('A high level is given', () => { + expect(getStorageCapacity(20)).toBe(2296600000); + }); + + it('The level is not a positive integer', () => { + expect(() => getStorageCapacity(-1)).toThrow('level must be an integer >= 0'); + }); +}); + +describe('Storage should be correctly returned when', () => { + it('A metal storage level is given', () => { + expect(getStorage(BUILDINGS[22], 1)).toEqual({ + metal: 1000, crystal: 0, deuterium: 0, energyCost: 0, capacity: 20000, + }); + }); + + it('A deuterium tank level is given', () => { + expect(getStorage(BUILDINGS[24], 4)).toEqual({ + metal: 8000, crystal: 8000, deuterium: 0, energyCost: 0, capacity: 140000, + }); + }); + + it('The building is not a storage', () => { + expect(() => getStorage(BUILDINGS[1], 1)).toThrow('must be a storage building entry'); + }); +}); + +describe('The required storage level should be returned when', () => { + it('An amount fits the free capacity', () => { + expect(getStorageLevelFor(10000)).toBe(0); + }); + + it('An amount needs a few levels', () => { + expect(getStorageLevelFor(10001)).toBe(1); + expect(getStorageLevelFor(75000)).toBe(3); + expect(getStorageLevelFor(75001)).toBe(4); + }); + + it('The amount is not a positive number', () => { + expect(() => getStorageLevelFor(-1)).toThrow('amount must be a positive number'); + }); +}); diff --git a/src/cost.js b/src/cost.js new file mode 100644 index 0000000..60994c2 --- /dev/null +++ b/src/cost.js @@ -0,0 +1,70 @@ +function assertLevel(targetLevel) { + if (!Number.isInteger(targetLevel) || targetLevel < 1) { + throw new Error(`targetLevel must be an integer >= 1, received ${targetLevel}`); + } +} + +/** + * Guard against the pre-4.0 habit of passing `Buildings[id].base` around. + * Every calculator now needs the whole entry, because the cost `factor` lives + * on it and not on its `base`. + */ +function assertEntry(entry, targetLevel) { + assertLevel(targetLevel); + + if (!entry || typeof entry !== 'object') { + throw new Error('expected a Buildings or Research entry, received nothing'); + } + + if (!entry.base || !entry.factor) { + const looksLikeABase = 'metal' in entry && 'crystal' in entry; + + throw new Error( + looksLikeABase + ? 'expected a Buildings or Research entry, received its `base`: pass Buildings[id], not Buildings[id].base' + : 'expected a Buildings or Research entry with a `base` and a `factor`', + ); + } +} + +function levelCost(baseCost, factor, level, roundTo) { + const cost = baseCost * factor ** (level - 1); + + if (roundTo) { + return Math.ceil(cost / roundTo) * roundTo; + } + + return Math.floor(cost); +} + +/** + * + * Return the cost of a building or a technology at a given level + * + * Works for anything carrying a `base` and a `factor`, so both + * `models/buildings.js` and `models/research.js` entries are accepted. + * Use the dedicated `getMetalMine`/`getCrystalMine`/... helpers when you also + * need the production or the consumption of a mine or a plant. + * + * @param {import('./types.js').BuildingEntry|import('./types.js').ResearchEntry} entry A Buildings or + * Research entry (not its `base`) + * @param {number} targetLevel The level to reach, >= 1 + * @returns {import('./types.js').Cost} The resources, and the energy, paid to reach that level + */ +function getCost(entry, targetLevel) { + assertEntry(entry, targetLevel); + + const { base, factor, roundTo } = entry; + // Only the space dock scales its energy cost on a factor of its own. + const energyFactor = entry.energyFactor ?? factor; + + return { + metal: levelCost(base.metal, factor, targetLevel, roundTo), + crystal: levelCost(base.crystal, factor, targetLevel, roundTo), + deuterium: levelCost(base.deuterium, factor, targetLevel, roundTo), + energyCost: levelCost(base.energyCost, energyFactor, targetLevel, roundTo), + }; +} + +export { assertLevel, assertEntry }; +export default getCost; diff --git a/src/cost.test.js b/src/cost.test.js new file mode 100644 index 0000000..832fc73 --- /dev/null +++ b/src/cost.test.js @@ -0,0 +1,78 @@ +import getCost from './cost.js'; +import BUILDINGS from './models/buildings.js'; +import RESEARCH from './models/research.js'; + +describe('Building cost should be correctly returned when', () => { + it('Level 1 is given, which is the base cost', () => { + expect(getCost(BUILDINGS[14], 1)).toEqual({ + metal: 400, crystal: 120, deuterium: 200, energyCost: 0, + }); + }); + + it('A facility level is given', () => { + // Robotics factory grows on a factor 2. + expect(getCost(BUILDINGS[14], 5)).toEqual({ + metal: 6400, crystal: 1920, deuterium: 3200, energyCost: 0, + }); + }); + + it('A storage level is given', () => { + expect(getCost(BUILDINGS[22], 10)).toEqual({ + metal: 512000, crystal: 0, deuterium: 0, energyCost: 0, + }); + }); + + it('The building really pays energy to be built', () => { + // The terraformer energy cost grows on the same factor 2 as its resources. + expect(getCost(BUILDINGS[33], 3)).toEqual({ + metal: 0, crystal: 200000, deuterium: 400000, energyCost: 4000, + }); + }); + + it('The building only consumes energy, and does not pay any to be built', () => { + // Metal mine level 10 consumes 259 energy but is paid in resources only. + expect(getCost(BUILDINGS[1], 10).energyCost).toBe(0); + }); + + it('The space dock scales its energy on its own factor', () => { + expect(getCost(BUILDINGS[36], 2)).toEqual({ + metal: 1000, crystal: 0, deuterium: 250, energyCost: 125, + }); + }); +}); + +describe('Research cost should be correctly returned when', () => { + it('A regular technology is given', () => { + expect(getCost(RESEARCH[113], 4)).toEqual({ + metal: 0, crystal: 6400, deuterium: 3200, energyCost: 0, + }); + }); + + it('Astrophysics is given, which is rounded up to the nearest hundred', () => { + expect(getCost(RESEARCH[124], 3)).toEqual({ + metal: 12300, crystal: 24500, deuterium: 12300, energyCost: 0, + }); + }); + + it('Graviton technology is given, which only costs energy', () => { + expect(getCost(RESEARCH[199], 1)).toEqual({ + metal: 0, crystal: 0, deuterium: 0, energyCost: 300000, + }); + }); +}); + +describe('Cost computation should throw when', () => { + it('The target level is not a positive integer', () => { + expect(() => getCost(BUILDINGS[14], 0)).toThrow('targetLevel must be an integer >= 1'); + expect(() => getCost(BUILDINGS[14], 1.5)).toThrow('targetLevel must be an integer >= 1'); + }); + + it('A `base` is passed instead of the whole entry', () => { + expect(() => getCost(BUILDINGS[14].base, 1)) + .toThrow('pass Buildings[id], not Buildings[id].base'); + }); + + it('Nothing is passed at all', () => { + expect(() => getCost(undefined, 1)).toThrow('received nothing'); + }); +}); diff --git a/src/fleets/combat.js b/src/fleets/combat.js new file mode 100644 index 0000000..f37b60c --- /dev/null +++ b/src/fleets/combat.js @@ -0,0 +1,291 @@ +import DESTROYABLE, { ATTRIBUTES } from '../models/destroyable.js'; +import getDebris from './getDebris.js'; + +const ROUNDS = 6; + +/** + * A unit with a rapid fire of 1250 legitimately chains hundreds of shots, but + * the chain is unbounded in principle, so cap it rather than risk a hang. + */ +const MAX_SHOTS = 10000; + +/** A shot weaker than this share of the target shield simply bounces off. */ +const BOUNCE_RATIO = 0.01; + +/** Below that share of its hull, a unit may explode at the end of the round. */ +const EXPLOSION_THRESHOLD = 0.7; + +/** Rapid-fire tables point at library ids, so we need the way back. */ +const LIBRARY_ID = new Map( + Object.entries(DESTROYABLE).map(([id, entry]) => [entry, Number(id)]), +); + +/** Callers routinely spread a model entry, which loses object identity. */ +const LIBRARY_ID_BY_OGAME_ID = new Map( + Object.entries(DESTROYABLE).map(([id, entry]) => [entry.ogameId, Number(id)]), +); + +function libraryIdOf(ship) { + const id = LIBRARY_ID.get(ship) ?? LIBRARY_ID_BY_OGAME_ID.get(ship.ogameId); + + if (id === undefined) { + // Without an id the rapid-fire tables, which point at ids, cannot resolve. + throw new Error('every ship must come from models/destroyable.js, or at least carry its ogameId'); + } + + return id; +} + +/** + * A tiny deterministic PRNG (mulberry32), so a battle can be replayed. + */ +function createRandom(seed) { + let state = seed >>> 0; + + return () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Two entries for the same ship would otherwise be counted twice. */ +function mergeFleet(fleet, side) { + if (!Array.isArray(fleet) || fleet.length === 0) { + throw new Error(`${side} fleet must be a non empty array of { ship, count }`); + } + + const merged = new Map(); + + for (const { ship, count } of fleet) { + if (!ship || !Array.isArray(ship.rapidFire)) { + throw new Error(`${side} fleet must hold entries of models/destroyable.js`); + } + + if (!Number.isInteger(count) || count < 0) { + throw new Error(`${side} fleet counts must be integers >= 0, received ${count}`); + } + + merged.set(ship, (merged.get(ship) ?? 0) + count); + } + + return [...merged].map(([ship, count]) => ({ ship, count })); +} + +/** + * Turn a fleet into individual units with live hull and shield values. Hull + * points are a tenth of the metal plus crystal cost. + */ +function deploy(fleet, techs) { + const { weapons = 0, shielding = 0, armour = 0 } = techs; + const units = []; + + for (const { ship, count } of fleet) { + const maxHull = (ship.structure / 10) * (1 + 0.1 * armour); + const maxShield = ship.shield * (1 + 0.1 * shielding); + const attack = ship.attack * (1 + 0.1 * weapons); + const libraryId = libraryIdOf(ship); + + for (let i = 0; i < count; i += 1) { + units.push({ + ship, libraryId, maxHull, hull: maxHull, maxShield, shield: maxShield, attack, + }); + } + } + + return units; +} + +function rapidFireAgainst(unit, target) { + return unit.ship.rapidFire.find((entry) => entry.target === target.libraryId)?.fire ?? 1; +} + +/** + * One unit fires at a random enemy, then keeps firing while its rapid-fire + * bonus rolls in its favour. + */ +function fire(unit, enemies, random) { + for (let shots = 0; shots < MAX_SHOTS; shots += 1) { + if (enemies.length === 0) { + return; + } + + const target = enemies[Math.floor(random() * enemies.length)]; + const damage = unit.attack; + + // A shot too weak to dent the shield bounces off without doing anything. + if (damage >= target.shield * BOUNCE_RATIO) { + if (damage <= target.shield) { + target.shield -= damage; + } else { + target.hull -= damage - target.shield; + target.shield = 0; + } + } + + const rapid = rapidFireAgainst(unit, target); + + // With a rapid fire of N, the unit shoots again with a (N - 1) / N chance. + if (rapid <= 1 || random() >= (rapid - 1) / rapid) { + return; + } + } +} + +/** + * Destroyed units are removed, damaged ones may explode, survivors get their + * shield back for the next round. + */ +function endRound(units, random) { + const survivors = []; + + for (const unit of units) { + if (unit.hull <= 0) { + continue; + } + + const integrity = unit.hull / unit.maxHull; + + if (integrity < EXPLOSION_THRESHOLD && random() < 1 - integrity) { + continue; + } + + unit.shield = unit.maxShield; + survivors.push(unit); + } + + return survivors; +} + +function summarise(units, initial) { + const remaining = new Map(); + + for (const unit of units) { + remaining.set(unit.ship, (remaining.get(unit.ship) ?? 0) + 1); + } + + const survivors = []; + const losses = []; + + for (const { ship, count } of initial) { + const left = remaining.get(ship) ?? 0; + + if (left > 0) { + survivors.push({ ship, count: left }); + } + + if (count - left > 0) { + losses.push({ ship, count: count - left }); + } + } + + return { survivors, losses }; +} + +function debrisOf(losses, options) { + const { debrisFactor, deuteriumDebrisFactor, defenseDebris } = options; + + return losses + // On most universes only ships leave debris behind. + .filter(({ ship }) => defenseDebris || ship.category === ATTRIBUTES.CATEGORIES.SHIPS) + .map(({ ship, count }) => getDebris(ship, count, debrisFactor, deuteriumDebrisFactor)) + .reduce((total, debris) => ({ + metal: total.metal + debris.metal, + crystal: total.crystal + debris.crystal, + deuterium: total.deuterium + debris.deuterium, + }), { metal: 0, crystal: 0, deuterium: 0 }); +} + +/** + * + * Simulate a battle between two fleets + * + * Follows the game rules: up to six rounds, every unit fires once per round at a + * random enemy, rapid fire grants extra shots, shots below 1% of the target + * shield bounce off, shields come back every round, and a unit under 70% hull + * may explode at the end of the round. + * + * A battle is random, so one run is one possible outcome. The `seed` makes a run + * reproducible; average several seeds to get a feel for the likely result. + * @param {object} attacker The attacking side + * @param {import('../types.js').FleetEntry[]} attacker.fleet Its ships + * @param {import('../types.js').CombatTechs} [attacker.techs] Its combat technology levels + * @param {object} defender The defending side, same shape, defenses included + * @param {object} [options] Simulation options + * @param {number} [options.seed] The PRNG seed, for a reproducible battle + * @param {number} [options.debrisFactor] The universe debris factor + * @param {number} [options.deuteriumDebrisFactor] The universe deuterium debris factor + * @param {boolean} [options.defenseDebris] Whether destroyed defenses leave debris + * @returns {{ + * winner: 'attacker'|'defender'|'draw', rounds: number, seed: number, + * attacker: {survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[]}, + * defender: {survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[]}, + * debris: import('../types.js').Resources, + * }} Who won, how long it took, what is left on each side, and the debris field + */ +function simulateCombat(attacker, defender, options = {}) { + const { + seed = Date.now(), + debrisFactor = 0.3, + deuteriumDebrisFactor = 0, + defenseDebris = false, + } = options; + + const random = createRandom(seed); + const attackerFleet = mergeFleet(attacker.fleet, 'attacker'); + const defenderFleet = mergeFleet(defender.fleet, 'defender'); + + let attackers = deploy(attackerFleet, attacker.techs ?? {}); + let defenders = deploy(defenderFleet, defender.techs ?? {}); + + let rounds = 0; + + while (rounds < ROUNDS && attackers.length > 0 && defenders.length > 0) { + rounds += 1; + + // Both sides shoot with the units they started the round with. + const shootingAttackers = [...attackers]; + const shootingDefenders = [...defenders]; + + for (const unit of shootingAttackers) { + fire(unit, defenders, random); + } + + for (const unit of shootingDefenders) { + fire(unit, attackers, random); + } + + attackers = endRound(attackers, random); + defenders = endRound(defenders, random); + } + + const attackerResult = summarise(attackers, attackerFleet); + const defenderResult = summarise(defenders, defenderFleet); + + let winner = 'draw'; + + if (attackers.length > 0 && defenders.length === 0) { + winner = 'attacker'; + } else if (defenders.length > 0 && attackers.length === 0) { + winner = 'defender'; + } + + return { + winner, + rounds, + seed, + attacker: attackerResult, + defender: defenderResult, + debris: debrisOf([...attackerResult.losses, ...defenderResult.losses], { + debrisFactor, + deuteriumDebrisFactor, + defenseDebris, + }), + }; +} + +export { createRandom, ROUNDS }; +export default simulateCombat; diff --git a/src/fleets/combat.test.js b/src/fleets/combat.test.js new file mode 100644 index 0000000..066b0ee --- /dev/null +++ b/src/fleets/combat.test.js @@ -0,0 +1,175 @@ +import simulateCombat from './combat.js'; +import DESTROYABLE from '../models/destroyable.js'; + +const fighters = (count) => [{ ship: DESTROYABLE[1], count }]; + +describe('A battle should', () => { + it('Be reproducible for a given seed', () => { + const battle = () => simulateCombat( + { fleet: fighters(500), techs: { weapons: 10, shielding: 10, armour: 10 } }, + { fleet: [{ ship: DESTROYABLE[201], count: 100 }], techs: { weapons: 8 } }, + { seed: 42 }, + ); + + expect(battle()).toEqual(battle()); + }); + + it('Give different outcomes for different seeds', () => { + const run = (seed) => simulateCombat( + { fleet: fighters(60) }, + { fleet: [{ ship: DESTROYABLE[201], count: 40 }] }, + { seed }, + ); + + const losses = [1, 2, 3, 4, 5].map((seed) => run(seed).attacker.losses[0]?.count ?? 0); + + expect(new Set(losses).size).toBeGreaterThan(1); + }); + + it('Be won by an overwhelming attacker', () => { + const result = simulateCombat( + { fleet: [{ ship: DESTROYABLE[8], count: 10 }] }, + { fleet: fighters(50) }, + { seed: 7 }, + ); + + expect(result.winner).toBe('attacker'); + expect(result.defender.survivors).toEqual([]); + expect(result.attacker.losses).toEqual([]); + }); + + it('Be won by an overwhelming defender', () => { + const result = simulateCombat( + { fleet: fighters(1) }, + { fleet: [{ ship: DESTROYABLE[8], count: 5 }] }, + { seed: 7 }, + ); + + expect(result.winner).toBe('defender'); + }); + + it('Never run for more than six rounds', () => { + const result = simulateCombat( + { fleet: [{ ship: DESTROYABLE[8], count: 20 }] }, + { fleet: [{ ship: DESTROYABLE[8], count: 20 }] }, + { seed: 3 }, + ); + + expect(result.rounds).toBeLessThanOrEqual(6); + }); + + it('Bounce shots that are too weak to dent the shield', () => { + // A light fighter hits for 50, which is under 1% of the 10.000 shield of a + // large shield dome; the dome hits back for 1, which the fighter shield + // soaks up. Neither side can hurt the other. + const result = simulateCombat( + { fleet: fighters(100) }, + { fleet: [{ ship: DESTROYABLE[208], count: 1 }] }, + { seed: 3 }, + ); + + expect(result.rounds).toBe(6); + expect(result.winner).toBe('draw'); + expect(result.attacker.losses).toEqual([]); + expect(result.defender.losses).toEqual([]); + }); + + it('Report the seed it ran with', () => { + expect(simulateCombat( + { fleet: fighters(10) }, + { fleet: fighters(10) }, + { seed: 99 }, + ).seed).toBe(99); + }); +}); + +describe('Technologies should matter, so that', () => { + const run = (techs, seed) => simulateCombat( + { fleet: fighters(100), techs }, + { fleet: [{ ship: DESTROYABLE[204], count: 5 }] }, + { seed }, + ); + + it('A better armed attacker loses fewer ships on average', () => { + const average = (techs) => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + .map((seed) => run(techs, seed).attacker.losses[0]?.count ?? 0) + .reduce((total, value) => total + value, 0) / 10; + + expect(average({ weapons: 16, shielding: 16, armour: 16 })) + .toBeLessThan(average({ weapons: 0, shielding: 0, armour: 0 })); + }); +}); + +describe('A spread model entry should still work, because', () => { + it('The ship is matched back by its ogameId', () => { + const copy = { ...DESTROYABLE[1] }; + const options = { seed: 21 }; + const defender = { fleet: [{ ship: DESTROYABLE[201], count: 30 }] }; + + expect(simulateCombat({ fleet: [{ ship: copy, count: 80 }] }, defender, options).winner) + .toBe(simulateCombat({ fleet: fighters(80) }, defender, options).winner); + }); +}); + +describe('Battle debris should', () => { + it('Come from the destroyed ships of both sides', () => { + const result = simulateCombat( + { fleet: fighters(200) }, + { fleet: fighters(200) }, + { seed: 11, debrisFactor: 0.3 }, + ); + + const lost = (side) => side.losses[0]?.count ?? 0; + const total = lost(result.attacker) + lost(result.defender); + + expect(result.debris.metal).toBe(total * 3000 * 0.3); + expect(result.debris.crystal).toBe(total * 1000 * 0.3); + expect(result.debris.deuterium).toBe(0); + }); + + it('Leave defenses out unless the universe says otherwise', () => { + const options = { seed: 5, debrisFactor: 0.3 }; + const attacker = { fleet: [{ ship: DESTROYABLE[8], count: 5 }] }; + const defender = { fleet: [{ ship: DESTROYABLE[201], count: 50 }] }; + + const without = simulateCombat(attacker, defender, options); + const with_ = simulateCombat(attacker, defender, { ...options, defenseDebris: true }); + + expect(without.debris.metal).toBe(0); + expect(with_.debris.metal).toBeGreaterThan(0); + }); + + it('Include deuterium when the universe has a deuterium debris factor', () => { + const result = simulateCombat( + { fleet: [{ ship: DESTROYABLE[3], count: 50 }] }, + { fleet: [{ ship: DESTROYABLE[3], count: 50 }] }, + { seed: 13, debrisFactor: 0.3, deuteriumDebrisFactor: 0.3 }, + ); + + expect(result.debris.deuterium).toBeGreaterThan(0); + }); +}); + +describe('Combat simulation should throw when', () => { + it('A fleet is empty', () => { + expect(() => simulateCombat({ fleet: [] }, { fleet: fighters(1) })) + .toThrow('attacker fleet must be a non empty array'); + }); + + it('A fleet holds something that is not a model entry', () => { + expect(() => simulateCombat({ fleet: fighters(1) }, { fleet: [{ ship: {}, count: 1 }] })) + .toThrow('defender fleet must hold entries of models/destroyable.js'); + }); + + it('A ship carries no id the rapid-fire tables can resolve', () => { + const anonymous = { ...DESTROYABLE[1], ogameId: undefined }; + + expect(() => simulateCombat({ fleet: [{ ship: anonymous, count: 1 }] }, { fleet: fighters(1) })) + .toThrow('or at least carry its ogameId'); + }); + + it('A count is not an integer', () => { + expect(() => simulateCombat({ fleet: [{ ship: DESTROYABLE[1], count: 1.5 }] }, { fleet: fighters(1) })) + .toThrow('attacker fleet counts must be integers >= 0'); + }); +}); diff --git a/src/fleets/distance.js b/src/fleets/distance.js new file mode 100644 index 0000000..5c3a160 --- /dev/null +++ b/src/fleets/distance.js @@ -0,0 +1,39 @@ +function assertCoordinates(coordinates, name) { + const { galaxy, system, position } = coordinates ?? {}; + + if (![galaxy, system, position].every((value) => Number.isInteger(value) && value > 0)) { + throw new Error(`${name} must be a { galaxy, system, position } of positive integers`); + } +} + +/** + * + * Return the distance between two coordinates, in OGame distance units + * + * The scale is not linear: crossing a galaxy costs far more than crossing a + * system, which costs far more than moving inside one. + * @param {import('../types.js').Coordinates} origin Where the fleet leaves from + * @param {import('../types.js').Coordinates} target Where it goes + * @returns {number} The distance + */ +function getDistance(origin, target) { + assertCoordinates(origin, 'origin'); + assertCoordinates(target, 'target'); + + if (origin.galaxy !== target.galaxy) { + return 20000 * Math.abs(origin.galaxy - target.galaxy); + } + + if (origin.system !== target.system) { + return 2700 + 95 * Math.abs(origin.system - target.system); + } + + if (origin.position !== target.position) { + return 1000 + 5 * Math.abs(origin.position - target.position); + } + + // Same planet: a moon to planet hop, or a planet to its own debris field. + return 5; +} + +export default getDistance; diff --git a/src/fleets/distance.test.js b/src/fleets/distance.test.js new file mode 100644 index 0000000..40f371b --- /dev/null +++ b/src/fleets/distance.test.js @@ -0,0 +1,36 @@ +import getDistance from './distance.js'; + +const at = (galaxy, system, position) => ({ galaxy, system, position }); + +describe('Distance should be correctly returned when', () => { + it('The galaxies differ', () => { + expect(getDistance(at(1, 1, 1), at(4, 1, 1))).toBe(60000); + expect(getDistance(at(4, 1, 1), at(1, 1, 1))).toBe(60000); + }); + + it('Only the systems differ', () => { + expect(getDistance(at(1, 1, 1), at(1, 1, 1))).toBe(5); + expect(getDistance(at(1, 1, 1), at(1, 51, 1))).toBe(2700 + 95 * 50); + }); + + it('Only the positions differ', () => { + expect(getDistance(at(1, 1, 1), at(1, 1, 2))).toBe(1005); + expect(getDistance(at(1, 1, 1), at(1, 1, 15))).toBe(1070); + }); + + it('Origin and target are the same planet', () => { + expect(getDistance(at(2, 30, 8), at(2, 30, 8))).toBe(5); + }); +}); + +describe('Distance should throw when', () => { + it('A coordinate is missing', () => { + expect(() => getDistance({ galaxy: 1, system: 1 }, at(1, 1, 1))) + .toThrow('origin must be a { galaxy, system, position }'); + }); + + it('A coordinate is not a positive integer', () => { + expect(() => getDistance(at(1, 1, 1), at(1, 1, 0))) + .toThrow('target must be a { galaxy, system, position }'); + }); +}); diff --git a/src/fleets/flight.js b/src/fleets/flight.js new file mode 100644 index 0000000..0c6a0c5 --- /dev/null +++ b/src/fleets/flight.js @@ -0,0 +1,105 @@ +import getDistance from './distance.js'; +import { getFleetSpeed, getActiveDrive } from './speed.js'; + +function assertSpeedPercent(speedPercent) { + if (!Number.isFinite(speedPercent) || speedPercent <= 0 || speedPercent > 100) { + throw new Error(`speedPercent must be between 1 and 100, received ${speedPercent}`); + } +} + +/** + * + * Return the one way flight time of a fleet + * + * `(10 + 35000 / speedPercent * sqrt(distance * 10 / fleetSpeed)) / universeFleetSpeed` + * @param {number} distance The distance to cross, from `getDistance` + * @param {number} fleetSpeed The speed of the slowest ship, from `getFleetSpeed` + * @param {number} [speedPercent] The fleet speed slider, 10 to 100 in game + * @param {number} [universeFleetSpeed] The universe fleet speed + * @returns {number} The flight time, in seconds + */ +function getFlightTime(distance, fleetSpeed, speedPercent = 100, universeFleetSpeed = 1) { + assertSpeedPercent(speedPercent); + + if (!(fleetSpeed > 0)) { + throw new Error(`fleetSpeed must be greater than 0, received ${fleetSpeed}`); + } + + const seconds = 10 + (35000 / speedPercent) * Math.sqrt((distance * 10) / fleetSpeed); + + return Math.round(seconds / universeFleetSpeed); +} + +/** + * + * Return the deuterium a fleet burns for a one way trip + * + * `1 + round(sum(consumption * count) * distance / 35000 * (speedPercent / 100 + 1) ** 2)` + * @param {import('../types.js').FleetEntry[]} fleet The ships taking off + * @param {number} distance The distance to cross, from `getDistance` + * @param {number} [speedPercent] The fleet speed slider, 10 to 100 in game + * @param {import('../types.js').Drives} [drives] Drive levels, needed because a + * ship that switched drive also changed its consumption + * @returns {number} The fuel needed, in deuterium + */ +function getFuelConsumption(fleet, distance, speedPercent = 100, drives = {}) { + assertSpeedPercent(speedPercent); + + if (!Array.isArray(fleet) || fleet.length === 0) { + throw new Error('fleet must be a non empty array of { ship, count }'); + } + + const speedFactor = (speedPercent / 100 + 1) ** 2; + + const consumption = fleet.reduce((total, { ship, count }) => { + // A ship that switched drive also changed how much it burns. + const { fuelConsumption } = getActiveDrive(ship, drives); + + return total + fuelConsumption * count; + }, 0); + + return 1 + Math.round((consumption * distance) / 35000 * speedFactor); +} + +/** + * + * Return everything about a trip: distance, duration, fuel and cargo left + * @param {import('../types.js').FleetEntry[]} fleet The ships taking off + * @param {import('../types.js').Coordinates} origin Where the fleet leaves from + * @param {import('../types.js').Coordinates} target Where it goes + * @param {object} [options] Trip options + * @param {number} [options.speedPercent] The fleet speed slider, 10 to 100 in game + * @param {number} [options.universeFleetSpeed] The universe fleet speed + * @param {import('../types.js').Drives} [options.drives] The drive technology levels + * @param {boolean} [options.roundTrip] Whether to account for the way back too + * @returns {{ + * distance: number, fleetSpeed: number, duration: number, fuel: number, + * cargo: number, cargoAfterFuel: number, + * }} The trip, with durations in seconds and `cargoAfterFuel` the room left once + * the fuel is loaded + */ +function getTrip(fleet, origin, target, options = {}) { + const { + speedPercent = 100, + universeFleetSpeed = 1, + drives = {}, + roundTrip = false, + } = options; + + const distance = getDistance(origin, target); + const fleetSpeed = getFleetSpeed(fleet, drives); + const oneWay = getFlightTime(distance, fleetSpeed, speedPercent, universeFleetSpeed); + const fuel = getFuelConsumption(fleet, distance, speedPercent, drives) * (roundTrip ? 2 : 1); + const cargo = fleet.reduce((total, { ship, count }) => total + ship.cargo * count, 0); + + return { + distance, + fleetSpeed, + duration: roundTrip ? oneWay * 2 : oneWay, + fuel, + cargo, + cargoAfterFuel: Math.max(0, cargo - fuel), + }; +} + +export { getFlightTime, getFuelConsumption, getTrip }; diff --git a/src/fleets/flight.test.js b/src/fleets/flight.test.js new file mode 100644 index 0000000..1c6cb63 --- /dev/null +++ b/src/fleets/flight.test.js @@ -0,0 +1,91 @@ +import { getFlightTime, getFuelConsumption, getTrip } from './flight.js'; +import DESTROYABLE from '../models/destroyable.js'; + +const at = (galaxy, system, position) => ({ galaxy, system, position }); + +describe('Flight time should be correctly returned when', () => { + it('The fleet flies at full speed', () => { + // 10 + 350 * sqrt(10050 / 20000) + expect(getFlightTime(1005, 20000)).toBe(258); + }); + + it('The fleet slows down to 10%', () => { + expect(getFlightTime(1005, 20000, 10)).toBe(2491); + }); + + it('The universe has a fleet speed of its own', () => { + expect(getFlightTime(1005, 20000, 100, 5)).toBe(52); + }); +}); + +describe('Fuel consumption should be correctly returned when', () => { + const oneFighter = [{ ship: DESTROYABLE[1], count: 1 }]; + + it('One ship flies at full speed', () => { + // 1 + round(20 * 1005 / 35000 * (1 + 1) ** 2) + expect(getFuelConsumption(oneFighter, 1005)).toBe(3); + }); + + it('Slowing down burns less fuel', () => { + expect(getFuelConsumption(oneFighter, 1005, 10)).toBe(2); + }); + + it('A ship that switched drive burns more', () => { + const cargo = [{ ship: DESTROYABLE[11], count: 100 }]; + + expect(getFuelConsumption(cargo, 7355, 100, { impulse: 5 })) + .toBeGreaterThan(getFuelConsumption(cargo, 7355, 100, { impulse: 4 })); + }); +}); + +describe('A trip should be correctly described when', () => { + const fleet = [{ ship: DESTROYABLE[12], count: 100 }]; + + it('A one way trip is given', () => { + const trip = getTrip(fleet, at(1, 1, 1), at(1, 50, 8), { + drives: { combustion: 12 }, + }); + + expect(trip).toEqual({ + distance: 7355, + fleetSpeed: 16500, + duration: 749, + fuel: 4204, + cargo: 2500000, + cargoAfterFuel: 2495796, + }); + }); + + it('A round trip is given', () => { + const oneWay = getTrip(fleet, at(1, 1, 1), at(1, 50, 8), { drives: { combustion: 12 } }); + const roundTrip = getTrip(fleet, at(1, 1, 1), at(1, 50, 8), { + drives: { combustion: 12 }, roundTrip: true, + }); + + expect(roundTrip.duration).toBe(oneWay.duration * 2); + expect(roundTrip.fuel).toBe(oneWay.fuel * 2); + }); + + it('The fuel eats into the cargo hold', () => { + // A colony ship burns far more than its own hold can carry over 8 galaxies. + const trip = getTrip([{ ship: DESTROYABLE[13], count: 1 }], at(1, 1, 1), at(9, 1, 1)); + + expect(trip.fuel).toBeGreaterThan(trip.cargo); + expect(trip.cargoAfterFuel).toBe(0); + }); +}); + +describe('Flight computation should throw when', () => { + it('The speed percentage is out of range', () => { + expect(() => getFlightTime(1005, 20000, 0)).toThrow('speedPercent must be between 1 and 100'); + expect(() => getFlightTime(1005, 20000, 150)).toThrow('speedPercent must be between 1 and 100'); + }); + + it('The fleet has no speed', () => { + expect(() => getFlightTime(1005, 0)).toThrow('fleetSpeed must be greater than 0'); + }); + + it('The fleet is empty', () => { + expect(() => getFuelConsumption([], 1005)).toThrow('fleet must be a non empty array'); + }); +}); diff --git a/src/fleets/getDebris.js b/src/fleets/getDebris.js index a134010..f068a76 100644 --- a/src/fleets/getDebris.js +++ b/src/fleets/getDebris.js @@ -1,19 +1,20 @@ /** * - * Returns the number of debris generated - * @param {number} shipId The ship identifier - * @param {number} number The number of ship - * @param {number} factor The universe debris factor - * @return {Object} The debris generated + * Return the debris field left behind by destroyed ships or defenses + * @param {import('../types.js').DestroyableEntry} ship An entry of models/destroyable.js + * @param {number} number The number of destroyed units + * @param {number} factor The universe debris factor, e.g. 0.3 for 30% + * @param {number} [deuteriumFactor] The universe deuterium debris factor, 0 on most universes + * @return {import('../types.js').Resources} The debris generated */ -function getDebris(ship, number, factor) { +function getDebris(ship, number, factor, deuteriumFactor = 0) { const { cost } = ship; - const metalDebris = cost.metal ? cost.metal * factor : 0; - const crystalDebris = cost.crystal ? cost.crystal * factor : 0; + const deuteriumCost = cost.deuterium ?? cost.deut ?? 0; return { - metal: metalDebris * number, - crystal: crystalDebris * number, + metal: (cost.metal ?? 0) * factor * number, + crystal: (cost.crystal ?? 0) * factor * number, + deuterium: deuteriumCost * deuteriumFactor * number, }; } diff --git a/src/fleets/getDebris.test.js b/src/fleets/getDebris.test.js index 2e06a88..20b2b44 100644 --- a/src/fleets/getDebris.test.js +++ b/src/fleets/getDebris.test.js @@ -5,12 +5,24 @@ describe('Debris should be correctly return when', () => { it('A 10 light fighter crash with 60% in harvest fields', () => { const ship = DESTROYABLE[1]; const debris = getDebris(ship, 10, 0.6); - expect(debris).toEqual({ metal: 18000, crystal: 6000 }); + expect(debris).toEqual({ metal: 18000, crystal: 6000, deuterium: 0 }); }); it('A 10 light fighter crash with 30% in harvest fields', () => { const ship = DESTROYABLE[1]; const debris = getDebris(ship, 10, 0.3); - expect(debris).toEqual({ metal: 9000, crystal: 3000 }); + expect(debris).toEqual({ metal: 9000, crystal: 3000, deuterium: 0 }); + }); + + it('The universe also puts deuterium in the debris field', () => { + const cruiser = DESTROYABLE[3]; + const debris = getDebris(cruiser, 10, 0.3, 0.3); + expect(debris).toEqual({ metal: 60000, crystal: 21000, deuterium: 6000 }); + }); + + it('A defense is destroyed', () => { + const plasmaTurret = DESTROYABLE[206]; + const debris = getDebris(plasmaTurret, 2, 0.5); + expect(debris).toEqual({ metal: 50000, crystal: 50000, deuterium: 0 }); }); }); diff --git a/src/fleets/index.js b/src/fleets/index.js index fe0d7cc..77a08e2 100644 --- a/src/fleets/index.js +++ b/src/fleets/index.js @@ -1,7 +1,19 @@ import getDebris from './getDebris.js'; +import getDistance from './distance.js'; +import getShipSpeed, { getActiveDrive, getFleetSpeed } from './speed.js'; +import { getFlightTime, getFuelConsumption, getTrip } from './flight.js'; +import simulateCombat from './combat.js'; const Fleets = { getDebris, + getDistance, + getShipSpeed, + getActiveDrive, + getFleetSpeed, + getFlightTime, + getFuelConsumption, + getTrip, + simulateCombat, }; export default Fleets; diff --git a/src/fleets/speed.js b/src/fleets/speed.js new file mode 100644 index 0000000..d74784a --- /dev/null +++ b/src/fleets/speed.js @@ -0,0 +1,108 @@ +import { ATTRIBUTES } from '../models/destroyable.js'; + +const { DRIVES } = ATTRIBUTES; + +/** Each drive level adds that share of the ship base speed. */ +const DRIVE_BONUS = Object.freeze({ + [DRIVES.COMBUSTION]: 0.1, + [DRIVES.IMPULSE]: 0.2, + [DRIVES.HYPERSPACE]: 0.3, + [DRIVES.NONE]: 0, +}); + +/** The research id backing each drive, handy to look the level up. */ +const DRIVE_RESEARCH = Object.freeze({ + [DRIVES.COMBUSTION]: 115, + [DRIVES.IMPULSE]: 117, + [DRIVES.HYPERSPACE]: 118, +}); + +function driveLevel(drives, drive) { + if (!Object.hasOwn(DRIVE_BONUS, drive)) { + throw new Error(`unknown drive ${drive}`); + } + + // eslint-disable-next-line security/detect-object-injection -- drive is a known key + const level = drives?.[drive] ?? 0; + + if (!Number.isInteger(level) || level < 0) { + throw new Error(`drives.${drive} must be an integer >= 0, received ${level}`); + } + + return level; +} + +function speedWith(baseSpeed, drive, drives) { + // eslint-disable-next-line security/detect-object-injection -- drive is a known key + return Math.floor(baseSpeed * (1 + DRIVE_BONUS[drive] * driveLevel(drives, drive))); +} + +/** + * + * Return the drive a ship actually flies on, and the speed it reaches + * + * A few ships switch to a better drive once the matching technology is high + * enough — a small cargo moves to the impulse drive at Impulse 5. When several + * drives are available the ship uses whichever ends up fastest. + * @param {import('../types.js').DestroyableEntry} ship An entry of models/destroyable.js + * @param {import('../types.js').Drives} [drives] Drive levels + * @returns {{drive: string, speed: number, fuelConsumption: number}} The active drive + */ +function getActiveDrive(ship, drives = {}) { + if (!ship || !ship.drive) { + throw new Error('expected an entry of models/destroyable.js'); + } + + const candidates = [ + { drive: ship.drive, speed: ship.speed, fuelConsumption: ship.fuelConsumption }, + ...(ship.driveUpgrades ?? []) + .filter((upgrade) => driveLevel(drives, upgrade.drive) >= upgrade.minLevel), + ]; + + return candidates + .map(({ drive, speed, fuelConsumption }) => ({ + drive, + speed: speedWith(speed, drive, drives), + fuelConsumption, + })) + .reduce((best, candidate) => (candidate.speed > best.speed ? candidate : best)); +} + +/** + * + * Return the speed of a ship, drive technologies included + * @param {import('../types.js').DestroyableEntry} ship An entry of models/destroyable.js + * @param {import('../types.js').Drives} [drives] Drive levels + * @returns {number} The speed the ship flies at + */ +function getShipSpeed(ship, drives = {}) { + return getActiveDrive(ship, drives).speed; +} + +/** + * + * Return the speed of a whole fleet, which is the speed of its slowest ship + * @param {import('../types.js').FleetEntry[]} fleet The ships taking off + * @param {import('../types.js').Drives} [drives] Drive levels + * @returns {number} The fleet speed + */ +function getFleetSpeed(fleet, drives = {}) { + if (!Array.isArray(fleet) || fleet.length === 0) { + throw new Error('fleet must be a non empty array of { ship, count }'); + } + + const speeds = fleet + .filter(({ count }) => count > 0) + .map(({ ship }) => getShipSpeed(ship, drives)); + + if (speeds.length === 0) { + throw new Error('fleet must hold at least one ship with a count above 0'); + } + + return Math.min(...speeds); +} + +export { + getActiveDrive, getFleetSpeed, DRIVE_BONUS, DRIVE_RESEARCH, +}; +export default getShipSpeed; diff --git a/src/fleets/speed.test.js b/src/fleets/speed.test.js new file mode 100644 index 0000000..cd4c24f --- /dev/null +++ b/src/fleets/speed.test.js @@ -0,0 +1,107 @@ +import getShipSpeed, { getActiveDrive, getFleetSpeed } from './speed.js'; +import DESTROYABLE, { ATTRIBUTES } from '../models/destroyable.js'; + +const { DRIVES } = ATTRIBUTES; + +describe('Ship speed should be correctly returned when', () => { + it('No drive is researched', () => { + expect(getShipSpeed(DESTROYABLE[1])).toBe(12500); + }); + + it('The combustion drive adds 10% of the base speed per level', () => { + expect(getShipSpeed(DESTROYABLE[1], { combustion: 10 })).toBe(25000); + }); + + it('The impulse drive adds 20% per level', () => { + expect(getShipSpeed(DESTROYABLE[3], { impulse: 5 })).toBe(30000); + }); + + it('The hyperspace drive adds 30% per level', () => { + expect(getShipSpeed(DESTROYABLE[7], { hyperspace: 10 })).toBe(20000); + }); + + it('Only the drive the ship actually uses counts', () => { + // The light fighter flies on combustion, hyperspace does nothing for it. + expect(getShipSpeed(DESTROYABLE[1], { hyperspace: 20 })).toBe(12500); + }); + + it('A ship with no drive at all is given', () => { + expect(getShipSpeed(DESTROYABLE[16], { combustion: 20 })).toBe(0); + }); +}); + +describe('Drive upgrades should be handled when', () => { + it('The small cargo has not reached impulse 5 yet', () => { + const drive = getActiveDrive(DESTROYABLE[11], { combustion: 6, impulse: 4 }); + + expect(drive.drive).toBe(DRIVES.COMBUSTION); + expect(drive.speed).toBe(8000); + expect(drive.fuelConsumption).toBe(10); + }); + + it('The small cargo reaches impulse 5 and switches drive', () => { + // Only the three fields the caller cares about come back, never the + // `minLevel` of the upgrade that happened to win. + expect(getActiveDrive(DESTROYABLE[11], { combustion: 6, impulse: 5 })).toEqual({ + drive: DRIVES.IMPULSE, + speed: 20000, + fuelConsumption: 20, + }); + }); + + it('A ship could use two upgrades and takes the fastest', () => { + const drive = getActiveDrive(DESTROYABLE[14], { impulse: 17, hyperspace: 15 }); + + expect(drive.drive).toBe(DRIVES.HYPERSPACE); + expect(drive.speed).toBe(6000 * (1 + 0.3 * 15)); + }); + + it('The upgrade is only worth it once the new drive is high enough', () => { + // Combustion 20 gives the recycler 6000, better than impulse 17 at 4000 x 4.4. + const drive = getActiveDrive(DESTROYABLE[14], { combustion: 20, impulse: 3 }); + + expect(drive.drive).toBe(DRIVES.COMBUSTION); + expect(drive.speed).toBe(6000); + }); +}); + +describe('Fleet speed should be correctly returned when', () => { + it('The fleet is mixed, taking the speed of its slowest ship', () => { + const fleet = [ + { ship: DESTROYABLE[1], count: 100 }, + { ship: DESTROYABLE[8], count: 1 }, + ]; + + // The deathstar sets the pace. + expect(getFleetSpeed(fleet, { combustion: 10, hyperspace: 10 })).toBe(400); + }); + + it('A ship is present with a count of zero', () => { + const fleet = [ + { ship: DESTROYABLE[1], count: 100 }, + { ship: DESTROYABLE[8], count: 0 }, + ]; + + expect(getFleetSpeed(fleet, { combustion: 10, hyperspace: 10 })).toBe(25000); + }); +}); + +describe('Speed computation should throw when', () => { + it('The ship is not a model entry', () => { + expect(() => getShipSpeed({})).toThrow('expected an entry of models/destroyable.js'); + }); + + it('A drive level is not a positive integer', () => { + expect(() => getShipSpeed(DESTROYABLE[1], { combustion: -1 })) + .toThrow('drives.combustion must be an integer >= 0'); + }); + + it('The fleet is empty', () => { + expect(() => getFleetSpeed([])).toThrow('fleet must be a non empty array'); + }); + + it('Every ship in the fleet has a count of zero', () => { + expect(() => getFleetSpeed([{ ship: DESTROYABLE[1], count: 0 }])) + .toThrow('fleet must hold at least one ship with a count above 0'); + }); +}); diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..36d314d --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,43 @@ +const DEFAULT_LANG = 'en'; + +const LANGS = Object.freeze(['en', 'fr']); + +/** + * + * Return the localised name of a model entry + * @param {{names: import('./types.js').Names}} entry An entry of Buildings, Destroyable or Research + * @param {import('./types.js').Lang} [lang] 'en' or 'fr', defaults to 'en' + * @returns {string} The localised name + */ +function getName(entry, lang = DEFAULT_LANG) { + if (!entry || !entry.names) { + throw new Error('entry has no names, is it a model entry?'); + } + + // eslint-disable-next-line security/detect-object-injection + return entry.names[lang] ?? entry.names[DEFAULT_LANG]; +} + +/** + * + * Look an entry up by any of its localised names, case and accent insensitive + * @param {Record} model Buildings, Destroyable or Research + * @param {string} name The name to look for, in any supported language + * @returns {object|undefined} The matching entry, or undefined + */ +function findByName(model, name) { + const normalize = (value) => value + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .trim() + .toLowerCase(); + + const needle = normalize(name); + + return Object.values(model).find((entry) => LANGS + // `lang` comes from the LANGS constant, never from the caller. + // eslint-disable-next-line security/detect-object-injection + .some((lang) => entry.names?.[lang] && normalize(entry.names[lang]) === needle)); +} + +export { getName, findByName, LANGS }; diff --git a/src/i18n.test.js b/src/i18n.test.js new file mode 100644 index 0000000..7d44b69 --- /dev/null +++ b/src/i18n.test.js @@ -0,0 +1,62 @@ +import { getName, findByName, LANGS } from './i18n.js'; +import BUILDINGS from './models/buildings.js'; +import DESTROYABLE from './models/destroyable.js'; +import RESEARCH from './models/research.js'; + +const MODELS = { BUILDINGS, DESTROYABLE, RESEARCH }; + +describe('Every model entry should be translated', () => { + it.each(Object.entries(MODELS))('%s has a name in every language', (_, model) => { + for (const [id, entry] of Object.entries(model)) { + for (const lang of LANGS) { + // eslint-disable-next-line security/detect-object-injection + expect(entry.names?.[lang], `${id} is missing its ${lang} name`).toBeTruthy(); + } + } + }); + + it.each(Object.entries(MODELS))('%s exposes an ogameId', (_, model) => { + for (const [id, entry] of Object.entries(model)) { + expect(entry.ogameId, `${id} is missing its ogameId`).toBeTypeOf('number'); + } + }); +}); + +describe('getName should return', () => { + it('The English name by default', () => { + expect(getName(BUILDINGS[1])).toBe('Metal Mine'); + expect(getName(DESTROYABLE[8])).toBe('Deathstar'); + expect(getName(RESEARCH[122])).toBe('Plasma Technology'); + }); + + it('The requested language', () => { + expect(getName(BUILDINGS[1], 'fr')).toBe('Mine de métal'); + }); + + it('The English name when the language is unknown', () => { + expect(getName(BUILDINGS[1], 'de')).toBe('Metal Mine'); + }); + + it('An error when the entry is not a model entry', () => { + expect(() => getName({})).toThrow('entry has no names'); + }); +}); + +describe('findByName should find an entry', () => { + it('By its English name', () => { + expect(findByName(BUILDINGS, 'Metal Storage').ogameId).toBe(22); + }); + + it('By its French name', () => { + expect(findByName(BUILDINGS, 'Hangar de métal').ogameId).toBe(22); + }); + + it('Whatever the case and the accents', () => { + expect(findByName(DESTROYABLE, 'etoile de la mort').ogameId).toBe(214); + expect(findByName(RESEARCH, 'ASTROPHYSICS').ogameId).toBe(124); + }); + + it('Or nothing when there is no match', () => { + expect(findByName(BUILDINGS, 'Kaelesh Sanctuary')).toBeUndefined(); + }); +}); diff --git a/src/index.js b/src/index.js index 1c81094..509d3d6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,16 +1,27 @@ import Trader from './trades/index.js'; import Building from './buildings/index.js'; import Fleets from './fleets/index.js'; +import Research from './research/index.js'; import Buildings from './models/buildings.js'; -import Destroyable from './models/destroyable.js'; +import Destroyable, { ATTRIBUTES } from './models/destroyable.js'; +import ResearchModel from './models/research.js'; +import { getName, findByName, LANGS } from './i18n.js'; const Ogame = { Trader, Building, Fleets, + Research, + i18n: { + getName, + findByName, + LANGS, + }, models: { Buildings, Destroyable, + Research: ResearchModel, + ATTRIBUTES, }, }; diff --git a/src/models/buildings.js b/src/models/buildings.js index ee0aca5..558e128 100644 --- a/src/models/buildings.js +++ b/src/models/buildings.js @@ -1,59 +1,319 @@ +const CATEGORIES = Object.freeze({ + RESOURCES: 'resources', + FACILITIES: 'facilities', + MOON: 'moon', +}); + +/** + * Every building of the game, keyed by the id used by this library. + * + * Ids 1 to 5 are historical and kept for backward compatibility; every other + * entry uses the id OGame itself uses. `ogameId` always holds the official id, + * so it is the field to rely on when talking to the game. + * + * - `names` holds the localised labels. + * - `factor` is the per-level cost multiplier: cost(level) = base * factor ** (level - 1). + * - `base` holds the level 1 values, with every energy and deuterium flow named + * after what it actually is: + * - `metal` / `crystal` / `deuterium` — the resources paid to build it, + * - `energyCost` — the energy paid to build it (Terraformer and Space Dock only), + * - `energyConsumption` — the energy it consumes once built (mines), + * - `deuteriumConsumption` — the deuterium it burns once built (Fusion Reactor), + * - `production` — what it produces, resources or energy. + */ const BUILDINGS = Object.freeze({ 1: { - name: 'Mine de métal', + ogameId: 1, + names: { en: 'Metal Mine', fr: 'Mine de métal' }, + category: CATEGORIES.RESOURCES, + factor: 1.5, base: { - production: 30, - consumption: 0, metal: 60, crystal: 15, - deutrium: 0, - energy: 10, + deuterium: 0, + energyCost: 0, + energyConsumption: 10, + deuteriumConsumption: 0, + production: 30, }, }, 2: { - name: 'Mine de cristal', + ogameId: 2, + names: { en: 'Crystal Mine', fr: 'Mine de cristal' }, + category: CATEGORIES.RESOURCES, + factor: 1.6, base: { - production: 20, - consumption: 0, metal: 48, crystal: 24, - deutrium: 0, - energy: 10, + deuterium: 0, + energyCost: 0, + energyConsumption: 10, + deuteriumConsumption: 0, + production: 20, }, }, 3: { - name: 'Synthétiseur de deutérium', + ogameId: 3, + names: { en: 'Deuterium Synthesizer', fr: 'Synthétiseur de deutérium' }, + category: CATEGORIES.RESOURCES, + factor: 1.5, base: { - production: 10, - consumption: 0, metal: 225, crystal: 75, - deutrium: 0, - energy: 20, + deuterium: 0, + energyCost: 0, + energyConsumption: 20, + deuteriumConsumption: 0, + production: 10, }, }, 4: { - name: 'Centrale électrique solaire', + ogameId: 4, + names: { en: 'Solar Plant', fr: 'Centrale électrique solaire' }, + category: CATEGORIES.RESOURCES, + factor: 1.5, base: { - production: 20, - consumption: 0, metal: 75, crystal: 30, - deutrium: 0, - energy: 0, + deuterium: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 20, }, }, 5: { - name: 'Centrale électrique de fusion', + ogameId: 12, + names: { en: 'Fusion Reactor', fr: 'Centrale électrique de fusion' }, + category: CATEGORIES.RESOURCES, + factor: 1.8, base: { - production: 30, - consumption: 10, metal: 900, crystal: 360, - deutrium: 180, - energy: 0, + deuterium: 180, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 10, + production: 30, + }, + }, + 14: { + ogameId: 14, + names: { en: 'Robotics Factory', fr: 'Usine de robots' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 400, + crystal: 120, + deuterium: 200, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 15: { + ogameId: 15, + names: { en: 'Nanite Factory', fr: 'Usine de nanites' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 1000000, + crystal: 500000, + deuterium: 100000, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 21: { + ogameId: 21, + names: { en: 'Shipyard', fr: 'Chantier spatial' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 400, + crystal: 200, + deuterium: 100, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 22: { + ogameId: 22, + names: { en: 'Metal Storage', fr: 'Hangar de métal' }, + category: CATEGORIES.RESOURCES, + factor: 2, + storage: 'metal', + base: { + metal: 1000, + crystal: 0, + deuterium: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 23: { + ogameId: 23, + names: { en: 'Crystal Storage', fr: 'Hangar de cristal' }, + category: CATEGORIES.RESOURCES, + factor: 2, + storage: 'crystal', + base: { + metal: 1000, + crystal: 500, + deuterium: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 24: { + ogameId: 24, + names: { en: 'Deuterium Tank', fr: 'Réservoir de deutérium' }, + category: CATEGORIES.RESOURCES, + factor: 2, + storage: 'deuterium', + base: { + metal: 1000, + crystal: 1000, + deuterium: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 31: { + ogameId: 31, + names: { en: 'Research Lab', fr: 'Laboratoire de recherche' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 200, + crystal: 400, + deuterium: 200, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 33: { + ogameId: 33, + names: { en: 'Terraformer', fr: 'Terraformeur' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 0, + crystal: 50000, + deuterium: 100000, + energyCost: 1000, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 34: { + ogameId: 34, + names: { en: 'Alliance Depot', fr: 'Dépôt de ravitaillement' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 20000, + crystal: 40000, + deuterium: 0, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 36: { + ogameId: 36, + names: { en: 'Space Dock', fr: 'Dock spatial' }, + category: CATEGORIES.FACILITIES, + factor: 5, + // The space dock is the only building whose energy cost grows on its own + // factor, hence the extra `energyFactor`. + energyFactor: 2.5, + base: { + metal: 200, + crystal: 0, + deuterium: 50, + energyCost: 50, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 41: { + ogameId: 41, + names: { en: 'Lunar Base', fr: 'Base lunaire' }, + category: CATEGORIES.MOON, + factor: 2, + base: { + metal: 20000, + crystal: 40000, + deuterium: 20000, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 42: { + ogameId: 42, + names: { en: 'Sensor Phalanx', fr: 'Phalange de capteur' }, + category: CATEGORIES.MOON, + factor: 2, + base: { + metal: 20000, + crystal: 40000, + deuterium: 20000, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 43: { + ogameId: 43, + names: { en: 'Jump Gate', fr: 'Porte de saut spatial' }, + category: CATEGORIES.MOON, + factor: 2, + base: { + metal: 2000000, + crystal: 4000000, + deuterium: 2000000, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, + }, + }, + 44: { + ogameId: 44, + names: { en: 'Missile Silo', fr: 'Silo de missiles' }, + category: CATEGORIES.FACILITIES, + factor: 2, + base: { + metal: 20000, + crystal: 20000, + deuterium: 1000, + energyCost: 0, + energyConsumption: 0, + deuteriumConsumption: 0, + production: 0, }, }, }); +export { CATEGORIES }; export default BUILDINGS; diff --git a/src/models/destroyable.js b/src/models/destroyable.js index ae0190d..28d5d4c 100644 --- a/src/models/destroyable.js +++ b/src/models/destroyable.js @@ -9,17 +9,40 @@ const ATTRIBUTES = Object.freeze({ DEFENSES: 'defenses', MISSILE: 'missiles', }, + DRIVES: { + COMBUSTION: 'combustion', + IMPULSE: 'impulse', + HYPERSPACE: 'hyperspace', + NONE: 'none', + }, }); +/** + * Every ship, defense and missile of the game, keyed by the id used by this + * library. `ogameId` holds the official OGame id (204 for the light fighter, + * 401 for the rocket launcher, ...) and is the field to rely on when talking + * to the game. + * + * - `names` holds the localised labels. + * - `structure` is the metal plus crystal cost; hull points are a tenth of it. + * - `speed` is the base speed, before any drive technology bonus. + * - `cargo` is the cargo capacity, `fuelConsumption` the base deuterium burned. + * - `drive` is the technology powering the ship, `driveUpgrades` the drives it + * switches to once the matching technology is high enough. + * - `rapidFire` lists the rapid-fire bonuses against other entries of this model, + * `target` being a library id. + */ const DESTROYABLE = Object.freeze({ 1: { - name: 'chasseur léger', + ogameId: 204, + names: { en: 'Light Fighter', fr: 'Chasseur léger' }, structure: 4000, shield: 10, attack: 50, speed: 12500, - fret: 50, - deutCost: 10, + cargo: 50, + fuelConsumption: 20, + drive: ATTRIBUTES.DRIVES.COMBUSTION, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -39,17 +62,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 3000, crystal: 1000, - deut: 0, + deuterium: 0, }, }, 2: { - name: 'chasseur lourd', + ogameId: 205, + names: { en: 'Heavy Fighter', fr: 'Chasseur lourd' }, structure: 10000, shield: 25, attack: 150, speed: 10000, - fret: 100, - deutCost: 37.5, + cargo: 100, + fuelConsumption: 75, + drive: ATTRIBUTES.DRIVES.IMPULSE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -73,17 +98,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 6000, crystal: 4000, - deut: 0, + deuterium: 0, }, }, 3: { - name: 'Croiseurs', + ogameId: 206, + names: { en: 'Cruiser', fr: 'Croiseur' }, structure: 27000, shield: 50, attack: 400, speed: 15000, - fret: 800, - deutCost: 150, + cargo: 800, + fuelConsumption: 300, + drive: ATTRIBUTES.DRIVES.IMPULSE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -111,17 +138,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 20000, crystal: 7000, - deut: 2000, + deuterium: 2000, }, }, 4: { - name: 'Vaisseau de bataille', + ogameId: 207, + names: { en: 'Battleship', fr: 'Vaisseau de bataille' }, structure: 60000, shield: 200, attack: 1000, speed: 10000, - fret: 1500, - deutCost: 250, + cargo: 1500, + fuelConsumption: 500, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -141,17 +170,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 45000, crystal: 15000, - deut: 0, + deuterium: 0, }, }, 5: { - name: 'Traqueur', + ogameId: 215, + names: { en: 'Battlecruiser', fr: 'Traqueur' }, structure: 70000, shield: 400, attack: 700, speed: 10000, - fret: 750, - deutCost: 125, + cargo: 750, + fuelConsumption: 250, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -191,17 +222,27 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 30000, crystal: 40000, - deut: 15000, + deuterium: 15000, }, }, 6: { - name: 'Bombardier', + ogameId: 211, + names: { en: 'Bomber', fr: 'Bombardier' }, structure: 75000, shield: 500, attack: 1000, speed: 4000, - fret: 500, - deutCost: 350, + cargo: 500, + fuelConsumption: 700, + drive: ATTRIBUTES.DRIVES.IMPULSE, + driveUpgrades: [ + { + drive: ATTRIBUTES.DRIVES.HYPERSPACE, + minLevel: 8, + speed: 5000, + fuelConsumption: 1000, + }, + ], type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -245,17 +286,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 50000, crystal: 25000, - deut: 15000, + deuterium: 15000, }, }, 7: { - name: 'Destructeur', + ogameId: 213, + names: { en: 'Destroyer', fr: 'Destructeur' }, structure: 110000, shield: 500, attack: 2000, speed: 5000, - fret: 2000, - deutCost: 500, + cargo: 2000, + fuelConsumption: 1000, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -283,17 +326,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 60000, crystal: 50000, - deut: 15000, + deuterium: 15000, }, }, 8: { - name: 'Étoile de la mort', + ogameId: 214, + names: { en: 'Deathstar', fr: 'Étoile de la mort' }, structure: 9000000, shield: 50000, attack: 200000, speed: 100, - fret: 1000000, - deutCost: 0.5, + cargo: 1000000, + fuelConsumption: 1, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -385,17 +430,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 5000000, crystal: 4000000, - deut: 1000000, + deuterium: 1000000, }, }, 9: { - name: 'Faucheur', + ogameId: 218, + names: { en: 'Reaper', fr: 'Faucheur' }, structure: 140000, shield: 700, attack: 2800, speed: 7000, - fret: 10000, - deutCost: 550, + cargo: 10000, + fuelConsumption: 1100, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -427,17 +474,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 85000, crystal: 55000, - deut: 20000, + deuterium: 20000, }, }, 10: { - name: 'Éclaireur', + ogameId: 219, + names: { en: 'Pathfinder', fr: 'Éclaireur' }, structure: 23000, shield: 100, attack: 200, speed: 12000, - fret: 10000, - deutCost: 150, + cargo: 10000, + fuelConsumption: 300, + drive: ATTRIBUTES.DRIVES.HYPERSPACE, type: ATTRIBUTES.TYPES.ATTACK, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -469,17 +518,27 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 8000, crystal: 15000, - deut: 8000, + deuterium: 8000, }, }, 11: { - name: 'Petit transporteur', + ogameId: 202, + names: { en: 'Small Cargo', fr: 'Petit transporteur' }, structure: 4000, shield: 10, attack: 5, speed: 5000, - fret: 5000, - deutCost: 5, + cargo: 5000, + fuelConsumption: 10, + drive: ATTRIBUTES.DRIVES.COMBUSTION, + driveUpgrades: [ + { + drive: ATTRIBUTES.DRIVES.IMPULSE, + minLevel: 5, + speed: 10000, + fuelConsumption: 20, + }, + ], type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -499,17 +558,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 2000, crystal: 2000, - deut: 0, + deuterium: 0, }, }, 12: { - name: 'Grand transporteur', + ogameId: 203, + names: { en: 'Large Cargo', fr: 'Grand transporteur' }, structure: 12000, shield: 25, attack: 5, speed: 7500, - fret: 25000, - deutCost: 25, + cargo: 25000, + fuelConsumption: 50, + drive: ATTRIBUTES.DRIVES.COMBUSTION, type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -529,17 +590,19 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 6000, crystal: 6000, - deut: 0, + deuterium: 0, }, }, 13: { - name: 'Vaisseaux de colonisation', + ogameId: 208, + names: { en: 'Colony Ship', fr: 'Vaisseau de colonisation' }, structure: 30000, shield: 100, attack: 50, speed: 2500, - fret: 7500, - deutCost: 500, + cargo: 7500, + fuelConsumption: 1000, + drive: ATTRIBUTES.DRIVES.IMPULSE, type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -559,17 +622,33 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 10000, crystal: 20000, - deut: 10000, + deuterium: 10000, }, }, 14: { - name: 'Recycleur', + ogameId: 209, + names: { en: 'Recycler', fr: 'Recycleur' }, structure: 16000, shield: 10, attack: 1, speed: 2000, - fret: 20000, - deutCost: 150, + cargo: 20000, + fuelConsumption: 300, + drive: ATTRIBUTES.DRIVES.COMBUSTION, + driveUpgrades: [ + { + drive: ATTRIBUTES.DRIVES.IMPULSE, + minLevel: 17, + speed: 4000, + fuelConsumption: 600, + }, + { + drive: ATTRIBUTES.DRIVES.HYPERSPACE, + minLevel: 15, + speed: 6000, + fuelConsumption: 900, + }, + ], type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [ @@ -589,136 +668,152 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 10000, crystal: 6000, - deut: 2000, + deuterium: 2000, }, }, 15: { - name: 'Sonde espionnage', + ogameId: 210, + names: { en: 'Espionage Probe', fr: "Sonde d'espionnage" }, structure: 1000, shield: 0, attack: 0, speed: 100000000, - fret: 5, - deutCost: 0.5, + cargo: 5, + fuelConsumption: 1, + drive: ATTRIBUTES.DRIVES.COMBUSTION, type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [], cost: { metal: 0, crystal: 1000, - deut: 0, + deuterium: 0, }, }, 16: { - name: 'Satellite solaire', + ogameId: 212, + names: { en: 'Solar Satellite', fr: 'Satellite solaire' }, structure: 2000, shield: 1, attack: 1, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [], cost: { metal: 0, crystal: 2000, - deut: 500, + deuterium: 500, }, }, 17: { - name: 'Foreuse', + ogameId: 217, + names: { en: 'Crawler', fr: 'Foreuse' }, structure: 4000, shield: 1, attack: 1, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.CIVIL, category: ATTRIBUTES.CATEGORIES.SHIPS, rapidFire: [], cost: { metal: 2000, crystal: 2000, - deut: 1000, + deuterium: 1000, }, }, 201: { - name: 'Lanceur missile', + ogameId: 401, + names: { en: 'Rocket Launcher', fr: 'Lanceur de missiles' }, structure: 2000, shield: 20, attack: 80, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 2000, crystal: 0, - deut: 0, + deuterium: 0, }, }, 202: { - name: 'Artillerie laser légère', + ogameId: 402, + names: { en: 'Light Laser', fr: 'Artillerie laser légère' }, structure: 2000, shield: 25, attack: 100, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 1500, crystal: 500, - deut: 0, + deuterium: 0, }, }, 203: { - name: 'Artillerie laser lourde', + ogameId: 403, + names: { en: 'Heavy Laser', fr: 'Artillerie laser lourde' }, structure: 8000, shield: 100, attack: 250, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 6000, crystal: 2000, - deut: 0, + deuterium: 0, }, }, 204: { - name: 'Canon de gauss', + ogameId: 404, + names: { en: 'Gauss Cannon', fr: 'Canon de Gauss' }, structure: 35000, shield: 200, attack: 1100, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 20000, crystal: 15000, - deut: 2000, + deuterium: 2000, }, }, 205: { - name: 'Artillerie à ion', + ogameId: 405, + names: { en: 'Ion Cannon', fr: 'Artillerie à ions' }, structure: 8000, shield: 500, attack: 150, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [ @@ -730,94 +825,105 @@ const DESTROYABLE = Object.freeze({ cost: { metal: 5000, crystal: 3000, - deut: 0, + deuterium: 0, }, }, 206: { - name: 'Lanceur plasma', + ogameId: 406, + names: { en: 'Plasma Turret', fr: 'Lanceur de plasma' }, structure: 100000, shield: 300, attack: 3000, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 50000, crystal: 50000, - deut: 30000, + deuterium: 30000, }, }, 207: { - name: 'Petit bouclier', + ogameId: 407, + names: { en: 'Small Shield Dome', fr: 'Petit bouclier' }, structure: 20000, shield: 2000, attack: 1, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 10000, crystal: 10000, - deut: 0, + deuterium: 0, }, }, 208: { - name: 'Grand bouclier', + ogameId: 408, + names: { en: 'Large Shield Dome', fr: 'Grand bouclier' }, structure: 100000, shield: 10000, attack: 1, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, category: ATTRIBUTES.CATEGORIES.DEFENSES, rapidFire: [], cost: { metal: 50000, crystal: 50000, - deut: 0, + deuterium: 0, }, }, 301: { - name: "Missile d'interception", + ogameId: 502, + names: { en: 'Anti-Ballistic Missile', fr: "Missile d'interception" }, structure: 8000, shield: 1, attack: 1, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, - category: ATTRIBUTES.CATEGORIES.DEFENSES, + category: ATTRIBUTES.CATEGORIES.MISSILE, rapidFire: [], cost: { metal: 8000, crystal: 0, - deut: 2000, + deuterium: 2000, }, }, 302: { - name: 'Missile interplanétaire', + ogameId: 503, + names: { en: 'Interplanetary Missile', fr: 'Missile interplanétaire' }, structure: 15000, shield: 1, attack: 12000, speed: 0, - fret: 0, - deutCost: 0, + cargo: 0, + fuelConsumption: 0, + drive: ATTRIBUTES.DRIVES.NONE, type: ATTRIBUTES.TYPES.DEFENSE, - category: ATTRIBUTES.CATEGORIES.DEFENSES, + category: ATTRIBUTES.CATEGORIES.MISSILE, rapidFire: [], cost: { metal: 12500, crystal: 2500, - deut: 10000, + deuterium: 10000, }, }, }); +export { ATTRIBUTES }; export default DESTROYABLE; diff --git a/src/models/models.test.js b/src/models/models.test.js new file mode 100644 index 0000000..75797ce --- /dev/null +++ b/src/models/models.test.js @@ -0,0 +1,139 @@ +import BUILDINGS from './buildings.js'; +import DESTROYABLE, { ATTRIBUTES } from './destroyable.js'; +import RESEARCH from './research.js'; + +const BUILDING_BASE_FIELDS = [ + 'metal', 'crystal', 'deuterium', + 'energyCost', 'energyConsumption', 'deuteriumConsumption', + 'production', +]; + +const RESEARCH_BASE_FIELDS = ['metal', 'crystal', 'deuterium', 'energyCost']; + +const entries = (model) => Object.entries(model); + +describe('Every building', () => { + it.each(entries(BUILDINGS))('%s has a complete base', (id, building) => { + for (const field of BUILDING_BASE_FIELDS) { + // eslint-disable-next-line security/detect-object-injection -- field is a constant + expect(building.base[field], `${id}.base.${field}`).toBeTypeOf('number'); + } + + expect(Object.keys(building.base).sort()).toEqual([...BUILDING_BASE_FIELDS].sort()); + }); + + it.each(entries(BUILDINGS))('%s has a usable cost factor', (id, building) => { + expect(building.factor, `${id}.factor`).toBeGreaterThan(1); + }); + + it.each(entries(BUILDINGS))('%s belongs to a known category', (id, building) => { + expect(['resources', 'facilities', 'moon']).toContain(building.category); + }); + + it('drops the fields 3.x deprecated', () => { + for (const building of Object.values(BUILDINGS)) { + expect(building).not.toHaveProperty('name'); + expect(building.base).not.toHaveProperty('deutrium'); + expect(building.base).not.toHaveProperty('energy'); + expect(building.base).not.toHaveProperty('consumption'); + } + }); + + it('only marks the storages as storages', () => { + const storages = Object.values(BUILDINGS).filter((building) => building.storage); + + expect(storages.map((building) => building.storage).sort()) + .toEqual(['crystal', 'deuterium', 'metal']); + }); + + it('only pays energy to build the terraformer and the space dock', () => { + const paying = entries(BUILDINGS) + .filter(([, building]) => building.base.energyCost > 0) + .map(([id]) => Number(id)); + + expect(paying).toEqual([33, 36]); + }); +}); + +describe('Every technology', () => { + it.each(entries(RESEARCH))('%s has a complete base', (id, research) => { + expect(Object.keys(research.base).sort()).toEqual([...RESEARCH_BASE_FIELDS].sort()); + + for (const field of RESEARCH_BASE_FIELDS) { + // eslint-disable-next-line security/detect-object-injection -- field is a constant + expect(research.base[field], `${id}.base.${field}`).toBeTypeOf('number'); + } + }); + + it.each(entries(RESEARCH))('%s is keyed by its own ogameId', (id, research) => { + expect(research.ogameId).toBe(Number(id)); + }); + + it('costs something to research', () => { + for (const [id, research] of entries(RESEARCH)) { + const { metal, crystal, deuterium, energyCost } = research.base; + + expect(metal + crystal + deuterium + energyCost, `${id} is free`).toBeGreaterThan(0); + } + }); +}); + +describe('Every ship, defense and missile', () => { + it.each(entries(DESTROYABLE))('%s has consistent stats', (id, unit) => { + expect(unit.structure, `${id}.structure`).toBe(unit.cost.metal + unit.cost.crystal); + expect(unit.shield, `${id}.shield`).toBeTypeOf('number'); + expect(unit.attack, `${id}.attack`).toBeTypeOf('number'); + expect(unit.cargo, `${id}.cargo`).toBeGreaterThanOrEqual(0); + expect(unit.fuelConsumption, `${id}.fuelConsumption`).toBeGreaterThanOrEqual(0); + }); + + it.each(entries(DESTROYABLE))('%s has a known drive', (id, unit) => { + expect(Object.values(ATTRIBUTES.DRIVES)).toContain(unit.drive); + }); + + it.each(entries(DESTROYABLE))('%s has a known type and category', (id, unit) => { + expect(Object.values(ATTRIBUTES.TYPES)).toContain(unit.type); + expect(Object.values(ATTRIBUTES.CATEGORIES)).toContain(unit.category); + }); + + it('aims its rapid fire at ids that exist', () => { + for (const [id, unit] of entries(DESTROYABLE)) { + for (const { target, fire } of unit.rapidFire) { + expect(DESTROYABLE, `${id} fires at unknown ${target}`).toHaveProperty(String(target)); + expect(fire).toBeGreaterThan(1); + } + } + }); + + it('drops the fields 3.x deprecated', () => { + for (const unit of Object.values(DESTROYABLE)) { + expect(unit).not.toHaveProperty('name'); + expect(unit).not.toHaveProperty('fret'); + expect(unit).not.toHaveProperty('deutCost'); + expect(unit.cost).not.toHaveProperty('deut'); + } + }); + + it('only gives a drive to something that can move', () => { + for (const [id, unit] of entries(DESTROYABLE)) { + const canMove = unit.drive !== ATTRIBUTES.DRIVES.NONE; + + expect(unit.speed > 0, `${id} speed and drive disagree`).toBe(canMove); + } + }); + + it('puts the missiles in the missile category', () => { + expect(DESTROYABLE[301].category).toBe(ATTRIBUTES.CATEGORIES.MISSILE); + expect(DESTROYABLE[302].category).toBe(ATTRIBUTES.CATEGORIES.MISSILE); + }); +}); + +describe('Across the models', () => { + it('no two entries share an ogameId', () => { + for (const model of [BUILDINGS, DESTROYABLE, RESEARCH]) { + const ids = Object.values(model).map((entry) => entry.ogameId); + + expect(new Set(ids).size).toBe(ids.length); + } + }); +}); diff --git a/src/models/research.js b/src/models/research.js new file mode 100644 index 0000000..9bcf3f1 --- /dev/null +++ b/src/models/research.js @@ -0,0 +1,169 @@ +const CATEGORIES = Object.freeze({ + BASIC: 'basic', + DRIVE: 'drive', + ADVANCED: 'advanced', + COMBAT: 'combat', +}); + +/** + * Every technology of the game, keyed by its official OGame id. + * + * Same shape as `models/buildings.js`: `names` for the localised labels, + * `base` for the level 1 cost and `factor` for the per-level multiplier — + * cost(level) = base * factor ** (level - 1). + * + * `base.energyCost` is only non zero for Graviton technology, which is paid + * entirely in energy. + */ +const RESEARCH = Object.freeze({ + 106: { + ogameId: 106, + names: { en: 'Espionage Technology', fr: 'Technologie Espionnage' }, + category: CATEGORIES.BASIC, + factor: 2, + base: { + metal: 200, crystal: 1000, deuterium: 200, energyCost: 0, + }, + }, + 108: { + ogameId: 108, + names: { en: 'Computer Technology', fr: 'Technologie Ordinateur' }, + category: CATEGORIES.BASIC, + factor: 2, + base: { + metal: 0, crystal: 400, deuterium: 600, energyCost: 0, + }, + }, + 109: { + ogameId: 109, + names: { en: 'Weapons Technology', fr: 'Technologie Armes' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 800, crystal: 200, deuterium: 0, energyCost: 0, + }, + }, + 110: { + ogameId: 110, + names: { en: 'Shielding Technology', fr: 'Technologie Bouclier' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 200, crystal: 600, deuterium: 0, energyCost: 0, + }, + }, + 111: { + ogameId: 111, + names: { en: 'Armour Technology', fr: 'Technologie Protection des vaisseaux spatiaux' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 1000, crystal: 0, deuterium: 0, energyCost: 0, + }, + }, + 113: { + ogameId: 113, + names: { en: 'Energy Technology', fr: 'Technologie Énergie' }, + category: CATEGORIES.BASIC, + factor: 2, + base: { + metal: 0, crystal: 800, deuterium: 400, energyCost: 0, + }, + }, + 114: { + ogameId: 114, + names: { en: 'Hyperspace Technology', fr: 'Technologie Hyperespace' }, + category: CATEGORIES.ADVANCED, + factor: 2, + base: { + metal: 0, crystal: 4000, deuterium: 2000, energyCost: 0, + }, + }, + 115: { + ogameId: 115, + names: { en: 'Combustion Drive', fr: 'Réacteur à combustion' }, + category: CATEGORIES.DRIVE, + factor: 2, + base: { + metal: 400, crystal: 0, deuterium: 600, energyCost: 0, + }, + }, + 117: { + ogameId: 117, + names: { en: 'Impulse Drive', fr: 'Réacteur à impulsion' }, + category: CATEGORIES.DRIVE, + factor: 2, + base: { + metal: 2000, crystal: 4000, deuterium: 600, energyCost: 0, + }, + }, + 118: { + ogameId: 118, + names: { en: 'Hyperspace Drive', fr: 'Propulsion hyperespace' }, + category: CATEGORIES.DRIVE, + factor: 2, + base: { + metal: 10000, crystal: 20000, deuterium: 6000, energyCost: 0, + }, + }, + 120: { + ogameId: 120, + names: { en: 'Laser Technology', fr: 'Technologie Laser' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 200, crystal: 100, deuterium: 0, energyCost: 0, + }, + }, + 121: { + ogameId: 121, + names: { en: 'Ion Technology', fr: 'Technologie Ions' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 1000, crystal: 300, deuterium: 100, energyCost: 0, + }, + }, + 122: { + ogameId: 122, + names: { en: 'Plasma Technology', fr: 'Technologie Plasma' }, + category: CATEGORIES.COMBAT, + factor: 2, + base: { + metal: 2000, crystal: 4000, deuterium: 1000, energyCost: 0, + }, + }, + 123: { + ogameId: 123, + names: { en: 'Intergalactic Research Network', fr: 'Réseau de recherche intergalactique' }, + category: CATEGORIES.ADVANCED, + factor: 2, + base: { + metal: 240000, crystal: 400000, deuterium: 160000, energyCost: 0, + }, + }, + 124: { + ogameId: 124, + names: { en: 'Astrophysics', fr: 'Astrophysique' }, + category: CATEGORIES.ADVANCED, + // Astrophysics is the only technology with a non-integer factor; the game + // also rounds each level cost up to the nearest hundred. + factor: 1.75, + roundTo: 100, + base: { + metal: 4000, crystal: 8000, deuterium: 4000, energyCost: 0, + }, + }, + 199: { + ogameId: 199, + names: { en: 'Graviton Technology', fr: 'Technologie Graviton' }, + category: CATEGORIES.ADVANCED, + factor: 3, + base: { + metal: 0, crystal: 0, deuterium: 0, energyCost: 300000, + }, + }, +}); + +export { CATEGORIES }; +export default RESEARCH; diff --git a/src/research/index.js b/src/research/index.js new file mode 100644 index 0000000..d315df0 --- /dev/null +++ b/src/research/index.js @@ -0,0 +1,9 @@ +import getCost from '../cost.js'; +import getResearchTime from './researchTime.js'; + +const Research = { + getResearchCost: getCost, + getResearchTime, +}; + +export default Research; diff --git a/src/research/researchTime.js b/src/research/researchTime.js new file mode 100644 index 0000000..1fcaea2 --- /dev/null +++ b/src/research/researchTime.js @@ -0,0 +1,25 @@ +import getCost, { assertLevel } from '../cost.js'; + +/** + * + * Return the time needed to research a technology at a given level + * + * time = (metal + crystal) / (1000 * (1 + labs)) hours, divided by the + * universe research speed. `labs` is the research lab level, or the sum of + * every connected lab when the Intergalactic Research Network is up. + * @param {import('../types.js').ResearchEntry} research A models/research.js entry + * @param {number} targetLevel The level to reach, >= 1 + * @param {number} [labLevel] The research lab level, or the sum of connected labs + * @param {number} [researchSpeed] The universe research speed + * @returns {number} The research time, in seconds + */ +function getResearchTime(research, targetLevel, labLevel = 0, researchSpeed = 1) { + assertLevel(targetLevel); + + const { metal, crystal } = getCost(research, targetLevel); + const divider = 1000 * (1 + labLevel) * researchSpeed; + + return Math.round(((metal + crystal) / divider) * 3600); +} + +export default getResearchTime; diff --git a/src/research/researchTime.test.js b/src/research/researchTime.test.js new file mode 100644 index 0000000..d326090 --- /dev/null +++ b/src/research/researchTime.test.js @@ -0,0 +1,18 @@ +import getResearchTime from './researchTime.js'; +import RESEARCH from '../models/research.js'; + +describe('Research time should be correctly returned when', () => { + it('A lab level is given', () => { + // Energy technology level 1 costs 800 crystal and 400 deuterium, + // so (0 + 800) / (1000 * (1 + 1)) hours. + expect(getResearchTime(RESEARCH[113], 1, 1)).toBe(1440); + }); + + it('No lab is given, which is the slowest case', () => { + expect(getResearchTime(RESEARCH[113], 1)).toBe(2880); + }); + + it('The universe research speed is given', () => { + expect(getResearchTime(RESEARCH[113], 1, 1, 4)).toBe(360); + }); +}); diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..82d838b --- /dev/null +++ b/src/types.js @@ -0,0 +1,146 @@ +/** + * Shared type definitions. + * + * This module holds no runtime code: it only exists so the JSDoc of the rest of + * the library — and the generated `.d.ts` files — can point at named types + * instead of a bare `object`. + * + * @module types + */ + +/** + * @typedef {'en' | 'fr'} Lang A supported language + */ + +/** + * @typedef {object} Names + * @property {string} en The English name + * @property {string} fr The French name + */ + +/** + * @typedef {object} BuildingBase The level 1 values of a building + * @property {number} metal Metal paid to build it + * @property {number} crystal Crystal paid to build it + * @property {number} deuterium Deuterium paid to build it + * @property {number} energyCost Energy paid to build it + * @property {number} energyConsumption Energy it consumes once built + * @property {number} deuteriumConsumption Deuterium it burns once built + * @property {number} production What it produces, resources or energy + */ + +/** + * @typedef {object} BuildingEntry An entry of `models/buildings.js` + * @property {number} ogameId The id OGame itself uses + * @property {Names} names The localised labels + * @property {string} category `resources`, `facilities` or `moon` + * @property {number} factor The per-level cost multiplier + * @property {BuildingBase} base The level 1 values + * @property {number} [energyFactor] A separate multiplier for the energy cost + * @property {string} [storage] The resource it stores, on storage buildings only + */ + +/** + * @typedef {object} ResearchBase The level 1 cost of a technology + * @property {number} metal Metal cost + * @property {number} crystal Crystal cost + * @property {number} deuterium Deuterium cost + * @property {number} energyCost Energy cost, Graviton technology only + */ + +/** + * @typedef {object} ResearchEntry An entry of `models/research.js` + * @property {number} ogameId The id OGame itself uses + * @property {Names} names The localised labels + * @property {string} category `basic`, `drive`, `advanced` or `combat` + * @property {number} factor The per-level cost multiplier + * @property {ResearchBase} base The level 1 cost + * @property {number} [roundTo] Rounding step, Astrophysics only + */ + +/** + * @typedef {object} Cost The resources a level costs + * @property {number} metal Metal cost + * @property {number} crystal Crystal cost + * @property {number} deuterium Deuterium cost + * @property {number} energyCost Energy cost + */ + +/** + * @typedef {object} BuildingInfo Everything about a building at a given level + * @property {number} metal Metal paid to reach that level + * @property {number} crystal Crystal paid to reach that level + * @property {number} deuterium Deuterium paid to reach that level + * @property {number} energyCost Energy paid to reach that level + * @property {number} energyConsumption Energy consumed at that level + * @property {number} deuteriumConsumption Deuterium burned at that level + * @property {number} production Resources, or energy, produced at that level + */ + +/** + * @typedef {object} RapidFire A rapid-fire bonus against another unit + * @property {number} target The library id of the unit it applies to + * @property {number} fire How many shots it can chain + */ + +/** + * @typedef {object} DriveUpgrade A better drive a ship switches to + * @property {string} drive The drive it moves to + * @property {number} minLevel The drive level that unlocks it + * @property {number} speed The new base speed + * @property {number} fuelConsumption The new base fuel consumption + */ + +/** + * @typedef {object} DestroyableEntry An entry of `models/destroyable.js` + * @property {number} ogameId The id OGame itself uses + * @property {Names} names The localised labels + * @property {number} structure The metal plus crystal cost, hull is a tenth of it + * @property {number} shield The base shield + * @property {number} attack The base attack + * @property {number} speed The base speed, before any drive bonus + * @property {number} cargo The cargo capacity + * @property {number} fuelConsumption The base deuterium burned + * @property {string} drive The drive powering it + * @property {string} type `attack`, `civil` or `defense` + * @property {string} category `ships`, `defenses` or `missiles` + * @property {RapidFire[]} rapidFire Its rapid-fire table + * @property {{metal: number, crystal: number, deuterium: number}} cost What it costs to build + * @property {DriveUpgrade[]} [driveUpgrades] The drives it can switch to + */ + +/** + * @typedef {object} Coordinates A place in the universe + * @property {number} galaxy The galaxy + * @property {number} system The system + * @property {number} position The position in the system + */ + +/** + * @typedef {object} FleetEntry A group of identical ships + * @property {DestroyableEntry} ship The ship + * @property {number} count How many of them + */ + +/** + * @typedef {object} Drives The drive technology levels + * @property {number} [combustion] The Combustion Drive level + * @property {number} [impulse] The Impulse Drive level + * @property {number} [hyperspace] The Hyperspace Drive level + */ + +/** + * @typedef {object} CombatTechs The combat technology levels + * @property {number} [weapons] The Weapons Technology level + * @property {number} [shielding] The Shielding Technology level + * @property {number} [armour] The Armour Technology level + */ + +/** + * @typedef {object} Resources An amount of each resource + * @property {number} metal Metal + * @property {number} crystal Crystal + * @property {number} deuterium Deuterium + */ + +export {}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b9786ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": [], + "allowJs": true, + "checkJs": false, + "strict": true, + "noEmitOnError": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "types", + "skipLibCheck": true + }, + "include": ["src/**/*.js"], + "exclude": ["src/**/*.test.js"] +}