ogamejs 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,8 +44,8 @@ Ogame.Fleets.getTrip(
44
44
  { galaxy: 3, system: 42, position: 6 },
45
45
  { drives: { combustion: 16, hyperspace: 12 } },
46
46
  );
47
- // → { distance: 40000, fleetSpeed: 19500, duration: 1595, fuel: 258287,
48
- // cargo: 6650000, cargoAfterFuel: 6391713 }
47
+ // → { distance: 40000, fleetSpeed: 19500, duration: 15862, fuel: 216148,
48
+ // cargo: 6650000, cargoAfterFuel: 6433852 }
49
49
 
50
50
  // And how it would go
51
51
  Ogame.Fleets.simulateCombat(
@@ -259,19 +259,29 @@ Ogame.Research.getResearchTime(Ogame.models.Research[122], 10, 12, 2); // Plasma
259
259
 
260
260
  ### `Ogame.Fleets`
261
261
 
262
- #### `getDistance(origin, target)`
262
+ #### `getDistance(origin, target, universe = {})`
263
263
 
264
264
  Coordinates are `{ galaxy, system, position }`. The scale is not linear.
265
265
 
266
266
  ```javascript
267
267
  Ogame.Fleets.getDistance({ galaxy: 1, system: 1, position: 1 }, { galaxy: 4, system: 1, position: 1 });
268
268
  // → 60000
269
+
270
+ // Most universes are donuts: the last galaxy touches the first
271
+ Ogame.Fleets.getDistance(
272
+ { galaxy: 1, system: 1, position: 1 },
273
+ { galaxy: 6, system: 1, position: 1 },
274
+ { galaxies: 6, systems: 499, donutGalaxy: true, donutSystem: true },
275
+ );
276
+ // → 20000
269
277
  ```
270
278
 
271
- #### `getShipSpeed(ship, drives = {})` / `getFleetSpeed(fleet, drives = {})` / `getActiveDrive(ship, drives = {})`
279
+ #### `getShipSpeed(ship, drives = {}, bonus = 0)` / `getFleetSpeed(fleet, drives = {})` / `getActiveDrive(ship, drives = {}, bonus = 0)`
272
280
 
273
281
  `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.
274
282
 
283
+ `bonus` adds to that, as a share of the base speed: `1` for the class bonus that doubles a ship's speed (Collector transporters, General warships), `0.05` for a 5% life form bonus. In a fleet, each entry carries its own `speedBonus`.
284
+
275
285
  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.
276
286
 
277
287
  ```javascript
@@ -287,11 +297,15 @@ Ogame.Fleets.getFleetSpeed([{ ship: Ogame.models.Destroyable[1], count: 100 }],
287
297
 
288
298
  #### `getFlightTime(distance, fleetSpeed, speedPercent = 100, universeFleetSpeed = 1)`
289
299
 
290
- `(10 + 35000 / speedPercent × √(distance × 10 / fleetSpeed)) / universeFleetSpeed`, in seconds.
300
+ `(10 + 350000 / speedPercent × √(distance × 10 / fleetSpeed)) / universeFleetSpeed`, in seconds — at 100%, the familiar `10 + 3500 × √(distance × 10 / fleetSpeed)`.
291
301
 
292
- #### `getFuelConsumption(fleet, distance, speedPercent = 100, drives = {})`
302
+ #### `getFuelConsumption(fleet, distance, speedPercent = 100, drives = {}, options = {})`
293
303
 
294
- `1 + round(Σ(consumption × count) × distance / 35000 × (speedPercent / 100 + 1)²)`, in deuterium.
304
+ Each ship type is billed on its own, at least one unit, then the bills add up:
305
+ `Σ max(1, round(consumption × count × distance / 35000 × (shipSpeedValue / 10 + 1)²))`, in deuterium.
306
+ `shipSpeedValue` is `speedPercent / 10` for the slowest ship and `speedPercent / 10 × √(fleetSpeed / shipSpeed)` for the faster ones it holds back, which burn less than at full throttle.
307
+
308
+ `consumption` is the ship's own after the universe factor (`options.deuteriumFactor`, `globalDeuteriumSaveFactor` in the API) and the entry's `fuelReduction` (General, life forms, capped at `MAX_FUEL_REDUCTION`, 90%), each rounded down and never below one unit.
295
309
 
296
310
  #### `getTrip(fleet, origin, target, options = {})`
297
311
 
@@ -304,10 +318,12 @@ Ogame.Fleets.getTrip(
304
318
  { galaxy: 3, system: 42, position: 6 },
305
319
  { drives: { combustion: 16, hyperspace: 12 }, speedPercent: 100, roundTrip: false },
306
320
  );
307
- // → { distance: 40000, fleetSpeed: 19500, duration: 1595, fuel: 258287,
308
- // cargo: 6650000, cargoAfterFuel: 6391713 }
321
+ // → { distance: 40000, fleetSpeed: 19500, duration: 15862, fuel: 216148,
322
+ // cargo: 6650000, cargoAfterFuel: 6433852 }
309
323
  ```
310
324
 
325
+ `options` also takes `universeFleetSpeed`, `deuteriumFactor` and `universe` (the donut shape passed to `getDistance`).
326
+
311
327
  `cargoAfterFuel` is what is left once the deuterium is loaded — the number that decides whether a raid is worth flying.
312
328
 
313
329
  #### `getDebris(ship, number, factor, deuteriumFactor = 0)`
@@ -321,7 +337,7 @@ Ogame.Fleets.getDebris(Ogame.models.Destroyable[1], 100, 0.3);
321
337
 
322
338
  #### `simulateCombat(attacker, defender, options = {})`
323
339
 
324
- 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.
340
+ 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 hit while under 70% hull may explode. Afterwards, destroyed defenses are rebuilt (70% chance each), the debris field gives the moon chance, and a winning attacker loads what its surviving ships can carry.
325
341
 
326
342
  ```javascript
327
343
  const battle = Ogame.Fleets.simulateCombat(
@@ -333,26 +349,50 @@ const battle = Ogame.Fleets.simulateCombat(
333
349
  fleet: [{ ship: Ogame.models.Destroyable[201], count: 200 }],
334
350
  techs: { weapons: 10, shielding: 10, armour: 10 },
335
351
  },
336
- { seed: 42, debrisFactor: 0.3 },
352
+ {
353
+ seed: 42,
354
+ debrisFactor: 0.3,
355
+ plunder: { resources: { metal: 500000, crystal: 200000, deuterium: 100000 }, hyperspaceLevel: 8 },
356
+ },
337
357
  );
338
358
  // → {
339
359
  // winner: 'attacker' | 'defender' | 'draw',
340
360
  // rounds: Number,
341
361
  // seed: Number,
342
362
  // attacker: { survivors: [{ ship, count }], losses: [{ ship, count }] },
343
- // defender: { survivors: [...], losses: [...] },
363
+ // defender: { survivors: [...], losses: [...], rebuilt: [...] },
344
364
  // debris: { metal, crystal, deuterium },
365
+ // moonChance: Number, // in %
366
+ // plunder: { metal, crystal, deuterium },
345
367
  // }
346
368
  ```
347
369
 
348
- 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:
370
+ | Option | Default | |
371
+ | --- | --- | --- |
372
+ | `seed` | `Date.now()` | replays the exact same fight |
373
+ | `debrisFactor` | `0.3` | share of the destroyed ships' metal and crystal left as debris |
374
+ | `deuteriumDebrisFactor` | `0` | same for deuterium |
375
+ | `defenseDebrisFactor` | `0` (`debrisFactor` with `defenseDebris: true`) | same for destroyed defenses |
376
+ | `repairFactor` | `0.7` | chance for each destroyed defense to be rebuilt |
377
+ | `plunder` | none | `{ resources, ratio = 0.5, hyperspaceLevel, hyperspaceMultiplier, bonus }`; the loot is spread like the resources on the planet |
378
+
379
+ #### `simulateCombats(attacker, defender, options = {})` / `getCombatStatistics(results)`
380
+
381
+ A battle is **random**, so one run is one possible outcome. `simulateCombats` runs the same battle `runs` times (100 by default, seeds `seed`, `seed + 1`…) and averages them; `getCombatStatistics` does the averaging on results you simulated yourself, for instance in batches from a Web Worker.
349
382
 
350
383
  ```javascript
351
- const runs = Array.from({ length: 100 }, (_, seed) => simulateCombat(a, d, { seed }));
352
- const winRate = runs.filter((run) => run.winner === 'attacker').length / runs.length;
384
+ Ogame.Fleets.simulateCombats(attacker, defender, { runs: 100, seed: 1, debrisFactor: 0.3 });
385
+ // → {
386
+ // runs: 100,
387
+ // outcomes: { attacker: 0.92, defender: 0, draw: 0.08 }, // shares, from 0 to 1
388
+ // rounds: 2.4,
389
+ // attacker: { survivors, losses, lostResources },
390
+ // defender: { survivors, losses, rebuilt, lostResources },
391
+ // debris, moonChance, plunder,
392
+ // }
353
393
  ```
354
394
 
355
- Destroyed defenses are left out of the debris field unless you pass `defenseDebris: true`. Defense repair after a battle is not modelled.
395
+ Counts are averages, so not whole numbers. `lostResources` is what the units lost for good cost: rebuilt defenses do not count.
356
396
 
357
397
  #### `getMoonbreakChance(moonSize, fleets)` / `getMoonbreakLosses(moonSize, fleets)` / `getMoonbreakWaves(rip)`
358
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ogamejs",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "OGame formulas as a dependency-free ESM library: building and research costs, production, build times, fleet debris and marketplace rates.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,5 +1,7 @@
1
1
  import DESTROYABLE, { ATTRIBUTES } from '../models/destroyable.js';
2
2
  import getDebris from './getDebris.js';
3
+ import { getCargoCapacity } from './expedition.js';
4
+ import { MOON_DEBRIS_THRESHOLD, MAX_MOON_CHANCE } from './moonLock.js';
3
5
 
4
6
  const ROUNDS = 6;
5
7
 
@@ -12,9 +14,18 @@ const MAX_SHOTS = 10000;
12
14
  /** A shot weaker than this share of the target shield simply bounces off. */
13
15
  const BOUNCE_RATIO = 0.01;
14
16
 
15
- /** Below that share of its hull, a unit may explode at the end of the round. */
17
+ /** Below that share of its hull, a unit hit by a shot may explode on the spot. */
16
18
  const EXPLOSION_THRESHOLD = 0.7;
17
19
 
20
+ /** The share of destroyed defenses that come back after a battle, on most universes. */
21
+ const DEFAULT_REPAIR_FACTOR = 0.7;
22
+
23
+ /** The share of the defender's resources a winning attacker can carry away. */
24
+ const DEFAULT_PLUNDER_RATIO = 0.5;
25
+
26
+ /** Every 100 000 units of debris add 1% to the moon chance. */
27
+ const MOON_DEBRIS_PER_PERCENT = MOON_DEBRIS_THRESHOLD / MAX_MOON_CHANCE;
28
+
18
29
  /** Rapid-fire tables point at library ids, so we need the way back. */
19
30
  const LIBRARY_ID = new Map(
20
31
  Object.entries(DESTROYABLE).map(([id, entry]) => [entry, Number(id)]),
@@ -106,6 +117,10 @@ function rapidFireAgainst(unit, target) {
106
117
  /**
107
118
  * One unit fires at a random enemy, then keeps firing while its rapid-fire
108
119
  * bonus rolls in its favour.
120
+ *
121
+ * Every shot that gets through may blow up a target left under 70% of its
122
+ * hull, so a unit hit several times in a round gets several rolls. A wrecked
123
+ * unit stays a target until the end of the round, and shots on it are wasted.
109
124
  */
110
125
  function fire(unit, enemies, random) {
111
126
  for (let shots = 0; shots < MAX_SHOTS; shots += 1) {
@@ -117,13 +132,19 @@ function fire(unit, enemies, random) {
117
132
  const damage = unit.attack;
118
133
 
119
134
  // A shot too weak to dent the shield bounces off without doing anything.
120
- if (damage >= target.shield * BOUNCE_RATIO) {
135
+ if (target.hull > 0 && damage >= target.shield * BOUNCE_RATIO) {
121
136
  if (damage <= target.shield) {
122
137
  target.shield -= damage;
123
138
  } else {
124
139
  target.hull -= damage - target.shield;
125
140
  target.shield = 0;
126
141
  }
142
+
143
+ const integrity = target.hull / target.maxHull;
144
+
145
+ if (target.hull > 0 && integrity < EXPLOSION_THRESHOLD && random() < 1 - integrity) {
146
+ target.hull = 0;
147
+ }
127
148
  }
128
149
 
129
150
  const rapid = rapidFireAgainst(unit, target);
@@ -135,11 +156,8 @@ function fire(unit, enemies, random) {
135
156
  }
136
157
  }
137
158
 
138
- /**
139
- * Destroyed units are removed, damaged ones may explode, survivors get their
140
- * shield back for the next round.
141
- */
142
- function endRound(units, random) {
159
+ /** Destroyed units are removed, survivors get their shield back for the next round. */
160
+ function endRound(units) {
143
161
  const survivors = [];
144
162
 
145
163
  for (const unit of units) {
@@ -147,12 +165,6 @@ function endRound(units, random) {
147
165
  continue;
148
166
  }
149
167
 
150
- const integrity = unit.hull / unit.maxHull;
151
-
152
- if (integrity < EXPLOSION_THRESHOLD && random() < 1 - integrity) {
153
- continue;
154
- }
155
-
156
168
  unit.shield = unit.maxShield;
157
169
  survivors.push(unit);
158
170
  }
@@ -186,12 +198,13 @@ function summarise(units, initial) {
186
198
  }
187
199
 
188
200
  function debrisOf(losses, options) {
189
- const { debrisFactor, deuteriumDebrisFactor, defenseDebris } = options;
201
+ const { debrisFactor, deuteriumDebrisFactor, defenseDebrisFactor } = options;
190
202
 
191
203
  return losses
192
- // On most universes only ships leave debris behind.
193
- .filter(({ ship }) => defenseDebris || ship.category === ATTRIBUTES.CATEGORIES.SHIPS)
194
- .map(({ ship, count }) => getDebris(ship, count, debrisFactor, deuteriumDebrisFactor))
204
+ // On most universes only ships leave debris behind, defenses have their own factor.
205
+ .map(({ ship, count }) => (ship.category === ATTRIBUTES.CATEGORIES.SHIPS
206
+ ? getDebris(ship, count, debrisFactor, deuteriumDebrisFactor)
207
+ : getDebris(ship, count, defenseDebrisFactor, deuteriumDebrisFactor > 0 ? defenseDebrisFactor : 0)))
195
208
  .reduce((total, debris) => ({
196
209
  metal: total.metal + debris.metal,
197
210
  crystal: total.crystal + debris.crystal,
@@ -199,17 +212,101 @@ function debrisOf(losses, options) {
199
212
  }), { metal: 0, crystal: 0, deuterium: 0 });
200
213
  }
201
214
 
215
+ /** Each destroyed defense is rebuilt, or not, on its own roll. */
216
+ function rebuild(losses, repairFactor, random) {
217
+ const rebuilt = [];
218
+
219
+ for (const { ship, count } of losses) {
220
+ if (ship.category !== ATTRIBUTES.CATEGORIES.DEFENSES) {
221
+ continue;
222
+ }
223
+
224
+ let back = 0;
225
+
226
+ for (let i = 0; i < count; i += 1) {
227
+ if (random() < repairFactor) {
228
+ back += 1;
229
+ }
230
+ }
231
+
232
+ if (back > 0) {
233
+ rebuilt.push({ ship, count: back });
234
+ }
235
+ }
236
+
237
+ return rebuilt;
238
+ }
239
+
240
+ /**
241
+ * The surviving ships carry away up to `ratio` of the defender's resources,
242
+ * spread in proportion to what lies on the planet.
243
+ */
244
+ function plunderOf(survivors, plunder) {
245
+ const {
246
+ resources, ratio = DEFAULT_PLUNDER_RATIO, hyperspaceLevel, hyperspaceMultiplier, bonus,
247
+ } = plunder;
248
+
249
+ const lootable = {
250
+ metal: (resources.metal ?? 0) * ratio,
251
+ crystal: (resources.crystal ?? 0) * ratio,
252
+ deuterium: (resources.deuterium ?? 0) * ratio,
253
+ };
254
+ const total = lootable.metal + lootable.crystal + lootable.deuterium;
255
+ const capacity = survivors
256
+ .map(({ ship, count }) => getCargoCapacity(ship, { hyperspaceLevel, hyperspaceMultiplier, bonus }) * count)
257
+ .reduce((sum, value) => sum + value, 0);
258
+
259
+ if (total === 0 || capacity >= total) {
260
+ return lootable;
261
+ }
262
+
263
+ const share = capacity / total;
264
+
265
+ return {
266
+ metal: Math.floor(lootable.metal * share),
267
+ crystal: Math.floor(lootable.crystal * share),
268
+ deuterium: Math.floor(lootable.deuterium * share),
269
+ };
270
+ }
271
+
272
+ function moonChanceOf(debris) {
273
+ const total = debris.metal + debris.crystal + debris.deuterium;
274
+
275
+ return Math.min(MAX_MOON_CHANCE, Math.floor(total / MOON_DEBRIS_PER_PERCENT));
276
+ }
277
+
278
+ /**
279
+ * @typedef {object} CombatResult One simulated battle
280
+ * @property {'attacker'|'defender'|'draw'} winner Who won
281
+ * @property {number} rounds How many rounds it lasted
282
+ * @property {number} seed The PRNG seed it ran with
283
+ * @property {{survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[]}} attacker
284
+ * What is left of the attacker, and what it lost
285
+ * @property {{
286
+ * survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[],
287
+ * rebuilt: import('../types.js').FleetEntry[],
288
+ * }} defender What is left of the defender, what it lost, and which of its lost defenses come back
289
+ * @property {import('../types.js').Resources} debris The debris field
290
+ * @property {number} moonChance The moon chance it gives, in %
291
+ * @property {import('../types.js').Resources} plunder What the attacker carries away
292
+ */
293
+
202
294
  /**
203
295
  *
204
296
  * Simulate a battle between two fleets
205
297
  *
206
298
  * Follows the game rules: up to six rounds, every unit fires once per round at a
207
299
  * random enemy, rapid fire grants extra shots, shots below 1% of the target
208
- * shield bounce off, shields come back every round, and a unit under 70% hull
209
- * may explode at the end of the round.
300
+ * shield bounce off, shields come back every round, and a unit hit while under
301
+ * 70% hull may explode.
302
+ *
303
+ * Afterwards, each destroyed defense is rebuilt with a `repairFactor` chance,
304
+ * the debris field gives the moon chance (1% per 100 000, up to 20%), and a
305
+ * winning attacker loads what it can of the defender's resources when
306
+ * `plunder` says what lies on the planet.
210
307
  *
211
308
  * A battle is random, so one run is one possible outcome. The `seed` makes a run
212
- * reproducible; average several seeds to get a feel for the likely result.
309
+ * reproducible; `getCombatStatistics` averages several of them.
213
310
  * @param {object} attacker The attacking side
214
311
  * @param {import('../types.js').FleetEntry[]} attacker.fleet Its ships
215
312
  * @param {import('../types.js').CombatTechs} [attacker.techs] Its combat technology levels
@@ -218,13 +315,16 @@ function debrisOf(losses, options) {
218
315
  * @param {number} [options.seed] The PRNG seed, for a reproducible battle
219
316
  * @param {number} [options.debrisFactor] The universe debris factor
220
317
  * @param {number} [options.deuteriumDebrisFactor] The universe deuterium debris factor
221
- * @param {boolean} [options.defenseDebris] Whether destroyed defenses leave debris
222
- * @returns {{
223
- * winner: 'attacker'|'defender'|'draw', rounds: number, seed: number,
224
- * attacker: {survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[]},
225
- * defender: {survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[]},
226
- * debris: import('../types.js').Resources,
227
- * }} Who won, how long it took, what is left on each side, and the debris field
318
+ * @param {boolean} [options.defenseDebris] Whether destroyed defenses leave debris, at `debrisFactor`
319
+ * @param {number} [options.defenseDebrisFactor] The universe defense debris factor, overrides `defenseDebris`
320
+ * @param {number} [options.repairFactor] The chance for a destroyed defense to be rebuilt, 0.7 by default
321
+ * @param {object} [options.plunder] What the attacker may carry away if it wins
322
+ * @param {import('../types.js').Resources} options.plunder.resources The resources on the planet
323
+ * @param {number} [options.plunder.ratio] The share of them up for grabs, 0.5 by default
324
+ * @param {number} [options.plunder.hyperspaceLevel] The attacker's Hyperspace Technology level
325
+ * @param {number} [options.plunder.hyperspaceMultiplier] The % of base cargo per level, 5 by default
326
+ * @param {number} [options.plunder.bonus] Any other cargo bonus, as a fraction
327
+ * @returns {CombatResult} Who won, how long it took, what is left on each side, and what the battle left behind
228
328
  */
229
329
  function simulateCombat(attacker, defender, options = {}) {
230
330
  const {
@@ -232,6 +332,9 @@ function simulateCombat(attacker, defender, options = {}) {
232
332
  debrisFactor = 0.3,
233
333
  deuteriumDebrisFactor = 0,
234
334
  defenseDebris = false,
335
+ defenseDebrisFactor = defenseDebris ? debrisFactor : 0,
336
+ repairFactor = DEFAULT_REPAIR_FACTOR,
337
+ plunder,
235
338
  } = options;
236
339
 
237
340
  const random = createRandom(seed);
@@ -258,8 +361,8 @@ function simulateCombat(attacker, defender, options = {}) {
258
361
  fire(unit, attackers, random);
259
362
  }
260
363
 
261
- attackers = endRound(attackers, random);
262
- defenders = endRound(defenders, random);
364
+ attackers = endRound(attackers);
365
+ defenders = endRound(defenders);
263
366
  }
264
367
 
265
368
  const attackerResult = summarise(attackers, attackerFleet);
@@ -273,19 +376,25 @@ function simulateCombat(attacker, defender, options = {}) {
273
376
  winner = 'defender';
274
377
  }
275
378
 
379
+ const debris = debrisOf([...attackerResult.losses, ...defenderResult.losses], {
380
+ debrisFactor,
381
+ deuteriumDebrisFactor,
382
+ defenseDebrisFactor,
383
+ });
384
+
276
385
  return {
277
386
  winner,
278
387
  rounds,
279
388
  seed,
280
389
  attacker: attackerResult,
281
- defender: defenderResult,
282
- debris: debrisOf([...attackerResult.losses, ...defenderResult.losses], {
283
- debrisFactor,
284
- deuteriumDebrisFactor,
285
- defenseDebris,
286
- }),
390
+ defender: { ...defenderResult, rebuilt: rebuild(defenderResult.losses, repairFactor, random) },
391
+ debris,
392
+ moonChance: moonChanceOf(debris),
393
+ plunder: plunder && winner === 'attacker'
394
+ ? plunderOf(attackerResult.survivors, plunder)
395
+ : { metal: 0, crystal: 0, deuterium: 0 },
287
396
  };
288
397
  }
289
398
 
290
- export { createRandom, ROUNDS };
399
+ export { createRandom, ROUNDS, DEFAULT_REPAIR_FACTOR, DEFAULT_PLUNDER_RATIO };
291
400
  export default simulateCombat;
@@ -0,0 +1,189 @@
1
+ import simulateCombat from './combat.js';
2
+
3
+ /** Enough runs for the averages to settle, without making a big battle wait. */
4
+ const DEFAULT_RUNS = 100;
5
+
6
+ const NO_RESOURCES = Object.freeze({ metal: 0, crystal: 0, deuterium: 0 });
7
+
8
+ function addResources(total, resources) {
9
+ return {
10
+ metal: total.metal + resources.metal,
11
+ crystal: total.crystal + resources.crystal,
12
+ deuterium: total.deuterium + resources.deuterium,
13
+ };
14
+ }
15
+
16
+ function scaleResources(resources, factor) {
17
+ return {
18
+ metal: resources.metal * factor,
19
+ crystal: resources.crystal * factor,
20
+ deuterium: resources.deuterium * factor,
21
+ };
22
+ }
23
+
24
+ function costOf(fleet) {
25
+ return fleet
26
+ .map(({ ship, count }) => ({
27
+ metal: (ship.cost.metal ?? 0) * count,
28
+ crystal: (ship.cost.crystal ?? 0) * count,
29
+ deuterium: (ship.cost.deuterium ?? ship.cost.deut ?? 0) * count,
30
+ }))
31
+ .reduce(addResources, NO_RESOURCES);
32
+ }
33
+
34
+ /** Two copies of the same model entry are the same ship. */
35
+ function keyOf(ship) {
36
+ return ship.ogameId ?? ship;
37
+ }
38
+
39
+ /** Sum the counts of every run, keeping the order ships first appear in. */
40
+ function addFleets(totals, fleet) {
41
+ for (const { ship, count } of fleet) {
42
+ const key = keyOf(ship);
43
+ const entry = totals.get(key);
44
+
45
+ if (entry) {
46
+ entry.count += count;
47
+ } else {
48
+ totals.set(key, { ship, count });
49
+ }
50
+ }
51
+ }
52
+
53
+ function averageFleet(totals, runs) {
54
+ return [...totals.values()].map(({ ship, count }) => ({ ship, count: count / runs }));
55
+ }
56
+
57
+ /**
58
+ * Take back what was rebuilt from what was lost, ship by ship.
59
+ * @param {import('../types.js').FleetEntry[]} losses What was destroyed
60
+ * @param {import('../types.js').FleetEntry[]} rebuilt What came back
61
+ * @returns {import('../types.js').FleetEntry[]} What is gone for good
62
+ */
63
+ function subtractFleet(losses, rebuilt) {
64
+ return losses
65
+ .map(({ ship, count }) => ({
66
+ ship,
67
+ count: count - (rebuilt.find((entry) => keyOf(entry.ship) === keyOf(ship))?.count ?? 0),
68
+ }))
69
+ .filter(({ count }) => count > 0);
70
+ }
71
+
72
+ /**
73
+ *
74
+ * Average a set of simulated battles
75
+ *
76
+ * Counts are averages, so they are not whole numbers. `lostResources` is what
77
+ * the units lost for good would cost to build again: every loss for the
78
+ * attacker, the defender's losses minus its rebuilt defenses.
79
+ * @param {import('./combat.js').CombatResult[]} results Battles from `simulateCombat`
80
+ * @returns {{
81
+ * runs: number,
82
+ * outcomes: {attacker: number, defender: number, draw: number},
83
+ * rounds: number,
84
+ * attacker: {
85
+ * survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[],
86
+ * lostResources: import('../types.js').Resources,
87
+ * },
88
+ * defender: {
89
+ * survivors: import('../types.js').FleetEntry[], losses: import('../types.js').FleetEntry[],
90
+ * rebuilt: import('../types.js').FleetEntry[], lostResources: import('../types.js').Resources,
91
+ * },
92
+ * debris: import('../types.js').Resources,
93
+ * moonChance: number,
94
+ * plunder: import('../types.js').Resources,
95
+ * }} The share of battles each side won (from 0 to 1), and the average of everything else
96
+ */
97
+ function getCombatStatistics(results) {
98
+ if (!Array.isArray(results) || results.length === 0) {
99
+ throw new Error('results must be a non empty array of simulateCombat results');
100
+ }
101
+
102
+ const runs = results.length;
103
+ const outcomes = { attacker: 0, defender: 0, draw: 0 };
104
+ const fleets = {
105
+ attackerSurvivors: new Map(),
106
+ attackerLosses: new Map(),
107
+ defenderSurvivors: new Map(),
108
+ defenderLosses: new Map(),
109
+ defenderRebuilt: new Map(),
110
+ };
111
+ let rounds = 0;
112
+ let moonChance = 0;
113
+ let debris = NO_RESOURCES;
114
+ let plunder = NO_RESOURCES;
115
+ let attackerLost = NO_RESOURCES;
116
+ let defenderLost = NO_RESOURCES;
117
+
118
+ for (const result of results) {
119
+ outcomes[result.winner] += 1;
120
+ rounds += result.rounds;
121
+ moonChance += result.moonChance;
122
+ debris = addResources(debris, result.debris);
123
+ plunder = addResources(plunder, result.plunder);
124
+ attackerLost = addResources(attackerLost, costOf(result.attacker.losses));
125
+ defenderLost = addResources(
126
+ defenderLost,
127
+ costOf(subtractFleet(result.defender.losses, result.defender.rebuilt)),
128
+ );
129
+
130
+ addFleets(fleets.attackerSurvivors, result.attacker.survivors);
131
+ addFleets(fleets.attackerLosses, result.attacker.losses);
132
+ addFleets(fleets.defenderSurvivors, result.defender.survivors);
133
+ addFleets(fleets.defenderLosses, result.defender.losses);
134
+ addFleets(fleets.defenderRebuilt, result.defender.rebuilt);
135
+ }
136
+
137
+ return {
138
+ runs,
139
+ outcomes: {
140
+ attacker: outcomes.attacker / runs,
141
+ defender: outcomes.defender / runs,
142
+ draw: outcomes.draw / runs,
143
+ },
144
+ rounds: rounds / runs,
145
+ attacker: {
146
+ survivors: averageFleet(fleets.attackerSurvivors, runs),
147
+ losses: averageFleet(fleets.attackerLosses, runs),
148
+ lostResources: scaleResources(attackerLost, 1 / runs),
149
+ },
150
+ defender: {
151
+ survivors: averageFleet(fleets.defenderSurvivors, runs),
152
+ losses: averageFleet(fleets.defenderLosses, runs),
153
+ rebuilt: averageFleet(fleets.defenderRebuilt, runs),
154
+ lostResources: scaleResources(defenderLost, 1 / runs),
155
+ },
156
+ debris: scaleResources(debris, 1 / runs),
157
+ moonChance: moonChance / runs,
158
+ plunder: scaleResources(plunder, 1 / runs),
159
+ };
160
+ }
161
+
162
+ /**
163
+ *
164
+ * Simulate the same battle many times and average the outcomes
165
+ *
166
+ * Run `i` uses the seed `seed + i`, so the whole set is reproducible.
167
+ * @param {Parameters<typeof simulateCombat>[0]} attacker The attacking side, as for `simulateCombat`
168
+ * @param {Parameters<typeof simulateCombat>[1]} defender The defending side, as for `simulateCombat`
169
+ * @param {Parameters<typeof simulateCombat>[2] & {runs?: number}} [options] The `simulateCombat`
170
+ * options, plus `runs`, how many battles to simulate (100 by default)
171
+ * @returns {ReturnType<typeof getCombatStatistics>} The averaged outcome, see `getCombatStatistics`
172
+ */
173
+ function simulateCombats(attacker, defender, options = {}) {
174
+ const { runs = DEFAULT_RUNS, seed = Date.now(), ...rest } = options;
175
+
176
+ if (!Number.isInteger(runs) || runs < 1) {
177
+ throw new Error(`runs must be an integer >= 1, received ${runs}`);
178
+ }
179
+
180
+ const results = [];
181
+
182
+ for (let i = 0; i < runs; i += 1) {
183
+ results.push(simulateCombat(attacker, defender, { ...rest, seed: seed + i }));
184
+ }
185
+
186
+ return getCombatStatistics(results);
187
+ }
188
+
189
+ export { getCombatStatistics, simulateCombats, DEFAULT_RUNS };
@@ -6,26 +6,49 @@ function assertCoordinates(coordinates, name) {
6
6
  }
7
7
  }
8
8
 
9
+ // In a donut universe the fleet takes the shorter way round.
10
+ function gap(from, to, size, donut) {
11
+ const direct = Math.abs(from - to);
12
+
13
+ if (!donut) {
14
+ return direct;
15
+ }
16
+
17
+ if (!Number.isInteger(size) || size <= 0) {
18
+ throw new Error(`a donut universe needs its size, received ${size}`);
19
+ }
20
+
21
+ return Math.min(direct, size - direct);
22
+ }
23
+
9
24
  /**
10
25
  *
11
26
  * Return the distance between two coordinates, in OGame distance units
12
27
  *
13
28
  * The scale is not linear: crossing a galaxy costs far more than crossing a
14
- * system, which costs far more than moving inside one.
29
+ * system, which costs far more than moving inside one. Most universes are
30
+ * donuts: the last galaxy (or system) touches the first, and the fleet takes the
31
+ * shorter way round.
15
32
  * @param {import('../types.js').Coordinates} origin Where the fleet leaves from
16
33
  * @param {import('../types.js').Coordinates} target Where it goes
34
+ * @param {import('../types.js').UniverseShape} [universe] How the universe wraps,
35
+ * flat when left out
17
36
  * @returns {number} The distance
18
37
  */
19
- function getDistance(origin, target) {
38
+ function getDistance(origin, target, universe = {}) {
20
39
  assertCoordinates(origin, 'origin');
21
40
  assertCoordinates(target, 'target');
22
41
 
42
+ const {
43
+ galaxies, systems, donutGalaxy = false, donutSystem = false,
44
+ } = universe;
45
+
23
46
  if (origin.galaxy !== target.galaxy) {
24
- return 20000 * Math.abs(origin.galaxy - target.galaxy);
47
+ return 20000 * gap(origin.galaxy, target.galaxy, galaxies, donutGalaxy);
25
48
  }
26
49
 
27
50
  if (origin.system !== target.system) {
28
- return 2700 + 95 * Math.abs(origin.system - target.system);
51
+ return 2700 + 95 * gap(origin.system, target.system, systems, donutSystem);
29
52
  }
30
53
 
31
54
  if (origin.position !== target.position) {