ogamejs 3.0.0 → 4.0.1

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.
Files changed (93) hide show
  1. package/README.md +492 -228
  2. package/package.json +51 -4
  3. package/src/buildings/buildTime.js +25 -0
  4. package/src/buildings/crystal.js +18 -36
  5. package/src/buildings/deut.js +19 -34
  6. package/src/buildings/fusion-reactor.js +19 -34
  7. package/src/buildings/index.js +11 -0
  8. package/src/buildings/info.js +42 -0
  9. package/src/buildings/infocompte.js +116 -57
  10. package/src/buildings/metal.js +13 -28
  11. package/src/buildings/production.js +150 -0
  12. package/src/buildings/solar-plant.js +13 -21
  13. package/src/buildings/storage.js +59 -0
  14. package/src/cost.js +70 -0
  15. package/src/fleets/combat.js +291 -0
  16. package/src/fleets/distance.js +39 -0
  17. package/src/fleets/flight.js +105 -0
  18. package/src/fleets/getDebris.js +11 -10
  19. package/src/fleets/index.js +12 -0
  20. package/src/fleets/speed.js +108 -0
  21. package/src/i18n.js +43 -0
  22. package/src/index.js +12 -1
  23. package/src/models/buildings.js +285 -25
  24. package/src/models/destroyable.js +216 -110
  25. package/src/models/research.js +169 -0
  26. package/src/research/index.js +9 -0
  27. package/src/research/researchTime.js +25 -0
  28. package/src/trades/parseRate.js +7 -2
  29. package/src/types.js +146 -0
  30. package/types/buildings/buildTime.d.ts +16 -0
  31. package/types/buildings/buildTime.d.ts.map +1 -0
  32. package/types/buildings/crystal.d.ts +12 -0
  33. package/types/buildings/crystal.d.ts.map +1 -0
  34. package/types/buildings/deut.d.ts +12 -0
  35. package/types/buildings/deut.d.ts.map +1 -0
  36. package/types/buildings/fusion-reactor.d.ts +15 -0
  37. package/types/buildings/fusion-reactor.d.ts.map +1 -0
  38. package/types/buildings/index.d.ts +27 -0
  39. package/types/buildings/index.d.ts.map +1 -0
  40. package/types/buildings/info.d.ts +17 -0
  41. package/types/buildings/info.d.ts.map +1 -0
  42. package/types/buildings/infocompte.d.ts +41 -0
  43. package/types/buildings/infocompte.d.ts.map +1 -0
  44. package/types/buildings/metal.d.ts +11 -0
  45. package/types/buildings/metal.d.ts.map +1 -0
  46. package/types/buildings/production.d.ts +78 -0
  47. package/types/buildings/production.d.ts.map +1 -0
  48. package/types/buildings/solar-plant.d.ts +10 -0
  49. package/types/buildings/solar-plant.d.ts.map +1 -0
  50. package/types/buildings/storage.d.ts +29 -0
  51. package/types/buildings/storage.d.ts.map +1 -0
  52. package/types/cost.d.ts +25 -0
  53. package/types/cost.d.ts.map +1 -0
  54. package/types/fleets/combat.d.ts +57 -0
  55. package/types/fleets/combat.d.ts.map +1 -0
  56. package/types/fleets/distance.d.ts +13 -0
  57. package/types/fleets/distance.d.ts.map +1 -0
  58. package/types/fleets/flight.d.ts +57 -0
  59. package/types/fleets/flight.d.ts.map +1 -0
  60. package/types/fleets/getDebris.d.ts +12 -0
  61. package/types/fleets/getDebris.d.ts.map +1 -0
  62. package/types/fleets/index.d.ts +18 -0
  63. package/types/fleets/index.d.ts.map +1 -0
  64. package/types/fleets/speed.d.ts +54 -0
  65. package/types/fleets/speed.d.ts.map +1 -0
  66. package/types/i18n.d.ts +23 -0
  67. package/types/i18n.d.ts.map +1 -0
  68. package/types/index.d.ts +1316 -0
  69. package/types/index.d.ts.map +1 -0
  70. package/types/models/buildings.d.ts +373 -0
  71. package/types/models/buildings.d.ts.map +1 -0
  72. package/types/models/destroyable.d.ts +695 -0
  73. package/types/models/destroyable.d.ts.map +1 -0
  74. package/types/models/research.d.ts +262 -0
  75. package/types/models/research.d.ts.map +1 -0
  76. package/types/research/index.d.ts +8 -0
  77. package/types/research/index.d.ts.map +1 -0
  78. package/types/research/researchTime.d.ts +16 -0
  79. package/types/research/researchTime.d.ts.map +1 -0
  80. package/types/trades/crystal.d.ts +6 -0
  81. package/types/trades/crystal.d.ts.map +1 -0
  82. package/types/trades/deut.d.ts +6 -0
  83. package/types/trades/deut.d.ts.map +1 -0
  84. package/types/trades/index.d.ts +12 -0
  85. package/types/trades/index.d.ts.map +1 -0
  86. package/types/trades/metal.d.ts +6 -0
  87. package/types/trades/metal.d.ts.map +1 -0
  88. package/types/trades/parseRate.d.ts +2 -0
  89. package/types/trades/parseRate.d.ts.map +1 -0
  90. package/types/trades/utils.d.ts +2 -0
  91. package/types/trades/utils.d.ts.map +1 -0
  92. package/types/types.d.ts +449 -0
  93. package/types/types.d.ts.map +1 -0
@@ -0,0 +1,150 @@
1
+ import getMetalMine from './metal.js';
2
+ import getCrystalMine from './crystal.js';
3
+ import getDeutSynth from './deut.js';
4
+ import BUILDINGS from '../models/buildings.js';
5
+
6
+ /**
7
+ * Plasma technology is worth more on metal than on crystal, and more on crystal
8
+ * than on deuterium.
9
+ */
10
+ const PLASMA_BONUS = Object.freeze({ metal: 0.01, crystal: 0.0066, deuterium: 0.0033 });
11
+
12
+ /** The geologist officer adds a flat 10% to the three mines. */
13
+ const GEOLOGIST_BONUS = 0.1;
14
+
15
+ /** The collector class adds 25% to mine production. */
16
+ const COLLECTOR_BONUS = 0.25;
17
+
18
+ /**
19
+ * Every planet produces this much per hour on its own, mines or not. It is a
20
+ * flat income: no bonus applies to it.
21
+ */
22
+ const BASE_INCOME = Object.freeze({ metal: 30, crystal: 15, deuterium: 0 });
23
+
24
+ const RESOURCES = Object.freeze(['metal', 'crystal', 'deuterium']);
25
+
26
+ function clampRatio(value, name) {
27
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
28
+ throw new Error(`${name} must be a number between 0 and 1, received ${value}`);
29
+ }
30
+
31
+ return value;
32
+ }
33
+
34
+ /**
35
+ *
36
+ * Return the production multiplier of each resource
37
+ *
38
+ * The multipliers add up, they are not compounded: plasma 15, a geologist and
39
+ * the collector class give 1 + 0.15 + 0.1 + 0.25 on metal.
40
+ * @param {object} [options] The bonuses that apply
41
+ * @param {number} [options.plasmaTech] The Plasma Technology level
42
+ * @param {boolean} [options.geologist] Whether the geologist officer is hired
43
+ * @param {boolean} [options.collectorClass] Whether the player plays collector
44
+ * @param {object} [options.items] Booster items in place, as fractions per
45
+ * resource — a 30% metal booster is `{ metal: 0.3 }`
46
+ * @returns {import('../types.js').Resources} One multiplier per resource
47
+ */
48
+ function getProductionBonus(options = {}) {
49
+ const {
50
+ plasmaTech = 0,
51
+ geologist = false,
52
+ collectorClass = false,
53
+ items = {},
54
+ } = options;
55
+
56
+ if (!Number.isInteger(plasmaTech) || plasmaTech < 0) {
57
+ throw new Error(`plasmaTech must be an integer >= 0, received ${plasmaTech}`);
58
+ }
59
+
60
+ const flat = (geologist ? GEOLOGIST_BONUS : 0) + (collectorClass ? COLLECTOR_BONUS : 0);
61
+
62
+ return Object.fromEntries(RESOURCES.map((resource) => {
63
+ /* eslint-disable security/detect-object-injection -- resource comes from RESOURCES */
64
+ const item = clampRatio(items[resource] ?? 0, `items.${resource}`);
65
+
66
+ return [resource, 1 + PLASMA_BONUS[resource] * plasmaTech + flat + item];
67
+ /* eslint-enable security/detect-object-injection */
68
+ }));
69
+ }
70
+
71
+ /**
72
+ *
73
+ * Return the hourly production of a whole planet, bonuses included
74
+ *
75
+ * Mine levels, planet position and temperature give the raw mine output; the
76
+ * plasma, officer, class and item bonuses are then applied, and the flat planet
77
+ * income is added on top.
78
+ * @param {object} planet The planet description
79
+ * @param {number} planet.metalMine The metal mine level
80
+ * @param {number} planet.crystalMine The crystal mine level
81
+ * @param {number} planet.deutSynth The deuterium synthesizer level
82
+ * @param {number} planet.position The planet position, 1 to 3 produce more crystal
83
+ * @param {number} planet.temperature The planet average temperature
84
+ * @param {number} [planet.universeSpeed] The universe economy speed
85
+ * @param {number} [planet.energyEfficiency] The share of the required energy that
86
+ * is actually available, between 0 and 1 — mines run at that rate when the
87
+ * planet is in an energy deficit
88
+ * @param {object} [bonuses] Passed straight to `getProductionBonus`
89
+ * @returns {import('../types.js').Resources & {energyConsumption: number, bonus: import('../types.js').Resources}}
90
+ * The hourly production, what it costs in energy, and the multipliers used
91
+ */
92
+ function getPlanetProduction(planet, bonuses = {}) {
93
+ const {
94
+ metalMine,
95
+ crystalMine,
96
+ deutSynth,
97
+ position,
98
+ temperature,
99
+ universeSpeed = 1,
100
+ energyEfficiency = 1,
101
+ } = planet;
102
+
103
+ clampRatio(energyEfficiency, 'energyEfficiency');
104
+
105
+ for (const [name, level] of Object.entries({ metalMine, crystalMine, deutSynth })) {
106
+ if (!Number.isInteger(level) || level < 0) {
107
+ throw new Error(`${name} must be an integer >= 0, received ${level}`);
108
+ }
109
+ }
110
+
111
+ // A mine that is not built yet neither produces nor consumes anything, and
112
+ // has no level 0 cost to speak of.
113
+ const idle = { production: 0, energyConsumption: 0 };
114
+ const mines = {
115
+ metal: metalMine > 0 ? getMetalMine(BUILDINGS[1], metalMine, universeSpeed) : idle,
116
+ crystal: crystalMine > 0
117
+ ? getCrystalMine(BUILDINGS[2], crystalMine, position, universeSpeed)
118
+ : idle,
119
+ deuterium: deutSynth > 0
120
+ ? getDeutSynth(BUILDINGS[3], deutSynth, temperature, universeSpeed)
121
+ : idle,
122
+ };
123
+
124
+ const bonus = getProductionBonus(bonuses);
125
+
126
+ const production = Object.fromEntries(RESOURCES.map((resource) => {
127
+ /* eslint-disable security/detect-object-injection -- resource comes from RESOURCES */
128
+ const mined = mines[resource].production * bonus[resource] * energyEfficiency;
129
+
130
+ return [resource, Math.floor(mined) + BASE_INCOME[resource] * universeSpeed];
131
+ /* eslint-enable security/detect-object-injection */
132
+ }));
133
+
134
+ return {
135
+ ...production,
136
+ energyConsumption: RESOURCES
137
+ // eslint-disable-next-line security/detect-object-injection
138
+ .reduce((total, resource) => total + mines[resource].energyConsumption, 0),
139
+ bonus,
140
+ };
141
+ }
142
+
143
+ export {
144
+ getProductionBonus,
145
+ PLASMA_BONUS,
146
+ GEOLOGIST_BONUS,
147
+ COLLECTOR_BONUS,
148
+ BASE_INCOME,
149
+ };
150
+ export default getPlanetProduction;
@@ -1,33 +1,25 @@
1
+ import buildingInfo from './info.js';
2
+
1
3
  function getEnergyProduction(baseProduction, targetLevel) {
2
4
  const levelFactor = 1.1 ** targetLevel;
3
- return Math.floor(baseProduction * targetLevel * levelFactor);
4
- }
5
5
 
6
- function getMetalCost(baseMetalCost, targetLevel) {
7
- const level = targetLevel - 1;
8
- return Math.floor(baseMetalCost * 1.5 ** level);
9
- }
10
-
11
- function getCrystalCost(baseCrystalCost, targetLevel) {
12
- const level = targetLevel - 1;
13
- return Math.floor(baseCrystalCost * 1.5 ** level);
6
+ return Math.floor(baseProduction * targetLevel * levelFactor);
14
7
  }
15
8
 
16
9
  /**
17
10
  *
18
- * Return information about the solar plant given a specific level
19
- * @param {object} solarPlant The solarPlant base information
20
- * @param {number} targetLevel
21
- * @returns {Object} informations about the solar plant at this specific level
11
+ * Return information about the solar plant at a given level
12
+ * @param {import('../types.js').BuildingEntry} solarPlant The solar plant entry, `Buildings[4]`
13
+ * @param {number} targetLevel The level to reach, >= 1
14
+ * @returns {import('../types.js').BuildingInfo} Cost of the plant, and the energy it produces
22
15
  */
23
16
  function getSolarPlant(solarPlant, targetLevel) {
24
- return {
25
- crystal: getCrystalCost(solarPlant.crystal, targetLevel),
26
- energy: 0,
27
- deuterium: 0,
28
- metal: getMetalCost(solarPlant.metal, targetLevel),
29
- production: getEnergyProduction(solarPlant.production, targetLevel),
30
- };
17
+ return buildingInfo(
18
+ solarPlant,
19
+ targetLevel,
20
+ 1,
21
+ (base) => getEnergyProduction(base.production, targetLevel),
22
+ );
31
23
  }
32
24
 
33
25
  export default getSolarPlant;
@@ -0,0 +1,59 @@
1
+ import getCost, { assertLevel } from '../cost.js';
2
+
3
+ /**
4
+ *
5
+ * Return the capacity of a storage building at a given level
6
+ *
7
+ * Level 0 is the free 10.000 units every planet starts with.
8
+ * @param {number} level The storage level, >= 0
9
+ * @returns {number} The protected capacity, in resource units
10
+ */
11
+ function getStorageCapacity(level) {
12
+ if (!Number.isInteger(level) || level < 0) {
13
+ throw new Error(`level must be an integer >= 0, received ${level}`);
14
+ }
15
+
16
+ return 5000 * Math.floor(2.5 * Math.exp((20 * level) / 33));
17
+ }
18
+
19
+ /**
20
+ *
21
+ * Return the cost and the capacity of a storage building at a given level
22
+ * @param {import('../types.js').BuildingEntry} storage A storage entry of models/buildings.js (22, 23 or 24)
23
+ * @param {number} targetLevel The level to reach, >= 1
24
+ * @returns {import('../types.js').Cost & {capacity: number}} The cost of that level plus the capacity
25
+ */
26
+ function getStorage(storage, targetLevel) {
27
+ assertLevel(targetLevel);
28
+
29
+ if (!storage || !storage.storage) {
30
+ throw new Error('storage must be a storage building entry (Buildings 22, 23 or 24)');
31
+ }
32
+
33
+ return {
34
+ ...getCost(storage, targetLevel),
35
+ capacity: getStorageCapacity(targetLevel),
36
+ };
37
+ }
38
+
39
+ /**
40
+ *
41
+ * Return the lowest storage level able to protect a given amount of resources
42
+ * @param {number} amount The amount of resources to protect
43
+ * @returns {number} The required storage level
44
+ */
45
+ function getStorageLevelFor(amount) {
46
+ if (!Number.isFinite(amount) || amount < 0) {
47
+ throw new Error(`amount must be a positive number, received ${amount}`);
48
+ }
49
+
50
+ let level = 0;
51
+ while (getStorageCapacity(level) < amount) {
52
+ level += 1;
53
+ }
54
+
55
+ return level;
56
+ }
57
+
58
+ export { getStorageCapacity, getStorageLevelFor };
59
+ export default getStorage;
package/src/cost.js ADDED
@@ -0,0 +1,70 @@
1
+ function assertLevel(targetLevel) {
2
+ if (!Number.isInteger(targetLevel) || targetLevel < 1) {
3
+ throw new Error(`targetLevel must be an integer >= 1, received ${targetLevel}`);
4
+ }
5
+ }
6
+
7
+ /**
8
+ * Guard against the pre-4.0 habit of passing `Buildings[id].base` around.
9
+ * Every calculator now needs the whole entry, because the cost `factor` lives
10
+ * on it and not on its `base`.
11
+ */
12
+ function assertEntry(entry, targetLevel) {
13
+ assertLevel(targetLevel);
14
+
15
+ if (!entry || typeof entry !== 'object') {
16
+ throw new Error('expected a Buildings or Research entry, received nothing');
17
+ }
18
+
19
+ if (!entry.base || !entry.factor) {
20
+ const looksLikeABase = 'metal' in entry && 'crystal' in entry;
21
+
22
+ throw new Error(
23
+ looksLikeABase
24
+ ? 'expected a Buildings or Research entry, received its `base`: pass Buildings[id], not Buildings[id].base'
25
+ : 'expected a Buildings or Research entry with a `base` and a `factor`',
26
+ );
27
+ }
28
+ }
29
+
30
+ function levelCost(baseCost, factor, level, roundTo) {
31
+ const cost = baseCost * factor ** (level - 1);
32
+
33
+ if (roundTo) {
34
+ return Math.ceil(cost / roundTo) * roundTo;
35
+ }
36
+
37
+ return Math.floor(cost);
38
+ }
39
+
40
+ /**
41
+ *
42
+ * Return the cost of a building or a technology at a given level
43
+ *
44
+ * Works for anything carrying a `base` and a `factor`, so both
45
+ * `models/buildings.js` and `models/research.js` entries are accepted.
46
+ * Use the dedicated `getMetalMine`/`getCrystalMine`/... helpers when you also
47
+ * need the production or the consumption of a mine or a plant.
48
+ *
49
+ * @param {import('./types.js').BuildingEntry|import('./types.js').ResearchEntry} entry A Buildings or
50
+ * Research entry (not its `base`)
51
+ * @param {number} targetLevel The level to reach, >= 1
52
+ * @returns {import('./types.js').Cost} The resources, and the energy, paid to reach that level
53
+ */
54
+ function getCost(entry, targetLevel) {
55
+ assertEntry(entry, targetLevel);
56
+
57
+ const { base, factor, roundTo } = entry;
58
+ // Only the space dock scales its energy cost on a factor of its own.
59
+ const energyFactor = entry.energyFactor ?? factor;
60
+
61
+ return {
62
+ metal: levelCost(base.metal, factor, targetLevel, roundTo),
63
+ crystal: levelCost(base.crystal, factor, targetLevel, roundTo),
64
+ deuterium: levelCost(base.deuterium, factor, targetLevel, roundTo),
65
+ energyCost: levelCost(base.energyCost, energyFactor, targetLevel, roundTo),
66
+ };
67
+ }
68
+
69
+ export { assertLevel, assertEntry };
70
+ export default getCost;
@@ -0,0 +1,291 @@
1
+ import DESTROYABLE, { ATTRIBUTES } from '../models/destroyable.js';
2
+ import getDebris from './getDebris.js';
3
+
4
+ const ROUNDS = 6;
5
+
6
+ /**
7
+ * A unit with a rapid fire of 1250 legitimately chains hundreds of shots, but
8
+ * the chain is unbounded in principle, so cap it rather than risk a hang.
9
+ */
10
+ const MAX_SHOTS = 10000;
11
+
12
+ /** A shot weaker than this share of the target shield simply bounces off. */
13
+ const BOUNCE_RATIO = 0.01;
14
+
15
+ /** Below that share of its hull, a unit may explode at the end of the round. */
16
+ const EXPLOSION_THRESHOLD = 0.7;
17
+
18
+ /** Rapid-fire tables point at library ids, so we need the way back. */
19
+ const LIBRARY_ID = new Map(
20
+ Object.entries(DESTROYABLE).map(([id, entry]) => [entry, Number(id)]),
21
+ );
22
+
23
+ /** Callers routinely spread a model entry, which loses object identity. */
24
+ const LIBRARY_ID_BY_OGAME_ID = new Map(
25
+ Object.entries(DESTROYABLE).map(([id, entry]) => [entry.ogameId, Number(id)]),
26
+ );
27
+
28
+ function libraryIdOf(ship) {
29
+ const id = LIBRARY_ID.get(ship) ?? LIBRARY_ID_BY_OGAME_ID.get(ship.ogameId);
30
+
31
+ if (id === undefined) {
32
+ // Without an id the rapid-fire tables, which point at ids, cannot resolve.
33
+ throw new Error('every ship must come from models/destroyable.js, or at least carry its ogameId');
34
+ }
35
+
36
+ return id;
37
+ }
38
+
39
+ /**
40
+ * A tiny deterministic PRNG (mulberry32), so a battle can be replayed.
41
+ */
42
+ function createRandom(seed) {
43
+ let state = seed >>> 0;
44
+
45
+ return () => {
46
+ state += 0x6d2b79f5;
47
+ let t = state;
48
+ t = Math.imul(t ^ (t >>> 15), t | 1);
49
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
50
+
51
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
52
+ };
53
+ }
54
+
55
+ /** Two entries for the same ship would otherwise be counted twice. */
56
+ function mergeFleet(fleet, side) {
57
+ if (!Array.isArray(fleet) || fleet.length === 0) {
58
+ throw new Error(`${side} fleet must be a non empty array of { ship, count }`);
59
+ }
60
+
61
+ const merged = new Map();
62
+
63
+ for (const { ship, count } of fleet) {
64
+ if (!ship || !Array.isArray(ship.rapidFire)) {
65
+ throw new Error(`${side} fleet must hold entries of models/destroyable.js`);
66
+ }
67
+
68
+ if (!Number.isInteger(count) || count < 0) {
69
+ throw new Error(`${side} fleet counts must be integers >= 0, received ${count}`);
70
+ }
71
+
72
+ merged.set(ship, (merged.get(ship) ?? 0) + count);
73
+ }
74
+
75
+ return [...merged].map(([ship, count]) => ({ ship, count }));
76
+ }
77
+
78
+ /**
79
+ * Turn a fleet into individual units with live hull and shield values. Hull
80
+ * points are a tenth of the metal plus crystal cost.
81
+ */
82
+ function deploy(fleet, techs) {
83
+ const { weapons = 0, shielding = 0, armour = 0 } = techs;
84
+ const units = [];
85
+
86
+ for (const { ship, count } of fleet) {
87
+ const maxHull = (ship.structure / 10) * (1 + 0.1 * armour);
88
+ const maxShield = ship.shield * (1 + 0.1 * shielding);
89
+ const attack = ship.attack * (1 + 0.1 * weapons);
90
+ const libraryId = libraryIdOf(ship);
91
+
92
+ for (let i = 0; i < count; i += 1) {
93
+ units.push({
94
+ ship, libraryId, maxHull, hull: maxHull, maxShield, shield: maxShield, attack,
95
+ });
96
+ }
97
+ }
98
+
99
+ return units;
100
+ }
101
+
102
+ function rapidFireAgainst(unit, target) {
103
+ return unit.ship.rapidFire.find((entry) => entry.target === target.libraryId)?.fire ?? 1;
104
+ }
105
+
106
+ /**
107
+ * One unit fires at a random enemy, then keeps firing while its rapid-fire
108
+ * bonus rolls in its favour.
109
+ */
110
+ function fire(unit, enemies, random) {
111
+ for (let shots = 0; shots < MAX_SHOTS; shots += 1) {
112
+ if (enemies.length === 0) {
113
+ return;
114
+ }
115
+
116
+ const target = enemies[Math.floor(random() * enemies.length)];
117
+ const damage = unit.attack;
118
+
119
+ // A shot too weak to dent the shield bounces off without doing anything.
120
+ if (damage >= target.shield * BOUNCE_RATIO) {
121
+ if (damage <= target.shield) {
122
+ target.shield -= damage;
123
+ } else {
124
+ target.hull -= damage - target.shield;
125
+ target.shield = 0;
126
+ }
127
+ }
128
+
129
+ const rapid = rapidFireAgainst(unit, target);
130
+
131
+ // With a rapid fire of N, the unit shoots again with a (N - 1) / N chance.
132
+ if (rapid <= 1 || random() >= (rapid - 1) / rapid) {
133
+ return;
134
+ }
135
+ }
136
+ }
137
+
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) {
143
+ const survivors = [];
144
+
145
+ for (const unit of units) {
146
+ if (unit.hull <= 0) {
147
+ continue;
148
+ }
149
+
150
+ const integrity = unit.hull / unit.maxHull;
151
+
152
+ if (integrity < EXPLOSION_THRESHOLD && random() < 1 - integrity) {
153
+ continue;
154
+ }
155
+
156
+ unit.shield = unit.maxShield;
157
+ survivors.push(unit);
158
+ }
159
+
160
+ return survivors;
161
+ }
162
+
163
+ function summarise(units, initial) {
164
+ const remaining = new Map();
165
+
166
+ for (const unit of units) {
167
+ remaining.set(unit.ship, (remaining.get(unit.ship) ?? 0) + 1);
168
+ }
169
+
170
+ const survivors = [];
171
+ const losses = [];
172
+
173
+ for (const { ship, count } of initial) {
174
+ const left = remaining.get(ship) ?? 0;
175
+
176
+ if (left > 0) {
177
+ survivors.push({ ship, count: left });
178
+ }
179
+
180
+ if (count - left > 0) {
181
+ losses.push({ ship, count: count - left });
182
+ }
183
+ }
184
+
185
+ return { survivors, losses };
186
+ }
187
+
188
+ function debrisOf(losses, options) {
189
+ const { debrisFactor, deuteriumDebrisFactor, defenseDebris } = options;
190
+
191
+ 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))
195
+ .reduce((total, debris) => ({
196
+ metal: total.metal + debris.metal,
197
+ crystal: total.crystal + debris.crystal,
198
+ deuterium: total.deuterium + debris.deuterium,
199
+ }), { metal: 0, crystal: 0, deuterium: 0 });
200
+ }
201
+
202
+ /**
203
+ *
204
+ * Simulate a battle between two fleets
205
+ *
206
+ * Follows the game rules: up to six rounds, every unit fires once per round at a
207
+ * 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.
210
+ *
211
+ * 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.
213
+ * @param {object} attacker The attacking side
214
+ * @param {import('../types.js').FleetEntry[]} attacker.fleet Its ships
215
+ * @param {import('../types.js').CombatTechs} [attacker.techs] Its combat technology levels
216
+ * @param {object} defender The defending side, same shape, defenses included
217
+ * @param {object} [options] Simulation options
218
+ * @param {number} [options.seed] The PRNG seed, for a reproducible battle
219
+ * @param {number} [options.debrisFactor] The universe debris factor
220
+ * @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
228
+ */
229
+ function simulateCombat(attacker, defender, options = {}) {
230
+ const {
231
+ seed = Date.now(),
232
+ debrisFactor = 0.3,
233
+ deuteriumDebrisFactor = 0,
234
+ defenseDebris = false,
235
+ } = options;
236
+
237
+ const random = createRandom(seed);
238
+ const attackerFleet = mergeFleet(attacker.fleet, 'attacker');
239
+ const defenderFleet = mergeFleet(defender.fleet, 'defender');
240
+
241
+ let attackers = deploy(attackerFleet, attacker.techs ?? {});
242
+ let defenders = deploy(defenderFleet, defender.techs ?? {});
243
+
244
+ let rounds = 0;
245
+
246
+ while (rounds < ROUNDS && attackers.length > 0 && defenders.length > 0) {
247
+ rounds += 1;
248
+
249
+ // Both sides shoot with the units they started the round with.
250
+ const shootingAttackers = [...attackers];
251
+ const shootingDefenders = [...defenders];
252
+
253
+ for (const unit of shootingAttackers) {
254
+ fire(unit, defenders, random);
255
+ }
256
+
257
+ for (const unit of shootingDefenders) {
258
+ fire(unit, attackers, random);
259
+ }
260
+
261
+ attackers = endRound(attackers, random);
262
+ defenders = endRound(defenders, random);
263
+ }
264
+
265
+ const attackerResult = summarise(attackers, attackerFleet);
266
+ const defenderResult = summarise(defenders, defenderFleet);
267
+
268
+ let winner = 'draw';
269
+
270
+ if (attackers.length > 0 && defenders.length === 0) {
271
+ winner = 'attacker';
272
+ } else if (defenders.length > 0 && attackers.length === 0) {
273
+ winner = 'defender';
274
+ }
275
+
276
+ return {
277
+ winner,
278
+ rounds,
279
+ seed,
280
+ attacker: attackerResult,
281
+ defender: defenderResult,
282
+ debris: debrisOf([...attackerResult.losses, ...defenderResult.losses], {
283
+ debrisFactor,
284
+ deuteriumDebrisFactor,
285
+ defenseDebris,
286
+ }),
287
+ };
288
+ }
289
+
290
+ export { createRandom, ROUNDS };
291
+ export default simulateCombat;
@@ -0,0 +1,39 @@
1
+ function assertCoordinates(coordinates, name) {
2
+ const { galaxy, system, position } = coordinates ?? {};
3
+
4
+ if (![galaxy, system, position].every((value) => Number.isInteger(value) && value > 0)) {
5
+ throw new Error(`${name} must be a { galaxy, system, position } of positive integers`);
6
+ }
7
+ }
8
+
9
+ /**
10
+ *
11
+ * Return the distance between two coordinates, in OGame distance units
12
+ *
13
+ * The scale is not linear: crossing a galaxy costs far more than crossing a
14
+ * system, which costs far more than moving inside one.
15
+ * @param {import('../types.js').Coordinates} origin Where the fleet leaves from
16
+ * @param {import('../types.js').Coordinates} target Where it goes
17
+ * @returns {number} The distance
18
+ */
19
+ function getDistance(origin, target) {
20
+ assertCoordinates(origin, 'origin');
21
+ assertCoordinates(target, 'target');
22
+
23
+ if (origin.galaxy !== target.galaxy) {
24
+ return 20000 * Math.abs(origin.galaxy - target.galaxy);
25
+ }
26
+
27
+ if (origin.system !== target.system) {
28
+ return 2700 + 95 * Math.abs(origin.system - target.system);
29
+ }
30
+
31
+ if (origin.position !== target.position) {
32
+ return 1000 + 5 * Math.abs(origin.position - target.position);
33
+ }
34
+
35
+ // Same planet: a moon to planet hop, or a planet to its own debris field.
36
+ return 5;
37
+ }
38
+
39
+ export default getDistance;