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.
- package/README.md +492 -228
- package/package.json +51 -4
- package/src/buildings/buildTime.js +25 -0
- package/src/buildings/crystal.js +18 -36
- package/src/buildings/deut.js +19 -34
- package/src/buildings/fusion-reactor.js +19 -34
- package/src/buildings/index.js +11 -0
- package/src/buildings/info.js +42 -0
- package/src/buildings/infocompte.js +116 -57
- package/src/buildings/metal.js +13 -28
- package/src/buildings/production.js +150 -0
- package/src/buildings/solar-plant.js +13 -21
- package/src/buildings/storage.js +59 -0
- package/src/cost.js +70 -0
- package/src/fleets/combat.js +291 -0
- package/src/fleets/distance.js +39 -0
- package/src/fleets/flight.js +105 -0
- package/src/fleets/getDebris.js +11 -10
- package/src/fleets/index.js +12 -0
- package/src/fleets/speed.js +108 -0
- package/src/i18n.js +43 -0
- package/src/index.js +12 -1
- package/src/models/buildings.js +285 -25
- package/src/models/destroyable.js +216 -110
- package/src/models/research.js +169 -0
- package/src/research/index.js +9 -0
- package/src/research/researchTime.js +25 -0
- package/src/trades/parseRate.js +7 -2
- package/src/types.js +146 -0
- package/types/buildings/buildTime.d.ts +16 -0
- package/types/buildings/buildTime.d.ts.map +1 -0
- package/types/buildings/crystal.d.ts +12 -0
- package/types/buildings/crystal.d.ts.map +1 -0
- package/types/buildings/deut.d.ts +12 -0
- package/types/buildings/deut.d.ts.map +1 -0
- package/types/buildings/fusion-reactor.d.ts +15 -0
- package/types/buildings/fusion-reactor.d.ts.map +1 -0
- package/types/buildings/index.d.ts +27 -0
- package/types/buildings/index.d.ts.map +1 -0
- package/types/buildings/info.d.ts +17 -0
- package/types/buildings/info.d.ts.map +1 -0
- package/types/buildings/infocompte.d.ts +41 -0
- package/types/buildings/infocompte.d.ts.map +1 -0
- package/types/buildings/metal.d.ts +11 -0
- package/types/buildings/metal.d.ts.map +1 -0
- package/types/buildings/production.d.ts +78 -0
- package/types/buildings/production.d.ts.map +1 -0
- package/types/buildings/solar-plant.d.ts +10 -0
- package/types/buildings/solar-plant.d.ts.map +1 -0
- package/types/buildings/storage.d.ts +29 -0
- package/types/buildings/storage.d.ts.map +1 -0
- package/types/cost.d.ts +25 -0
- package/types/cost.d.ts.map +1 -0
- package/types/fleets/combat.d.ts +57 -0
- package/types/fleets/combat.d.ts.map +1 -0
- package/types/fleets/distance.d.ts +13 -0
- package/types/fleets/distance.d.ts.map +1 -0
- package/types/fleets/flight.d.ts +57 -0
- package/types/fleets/flight.d.ts.map +1 -0
- package/types/fleets/getDebris.d.ts +12 -0
- package/types/fleets/getDebris.d.ts.map +1 -0
- package/types/fleets/index.d.ts +18 -0
- package/types/fleets/index.d.ts.map +1 -0
- package/types/fleets/speed.d.ts +54 -0
- package/types/fleets/speed.d.ts.map +1 -0
- package/types/i18n.d.ts +23 -0
- package/types/i18n.d.ts.map +1 -0
- package/types/index.d.ts +1316 -0
- package/types/index.d.ts.map +1 -0
- package/types/models/buildings.d.ts +373 -0
- package/types/models/buildings.d.ts.map +1 -0
- package/types/models/destroyable.d.ts +695 -0
- package/types/models/destroyable.d.ts.map +1 -0
- package/types/models/research.d.ts +262 -0
- package/types/models/research.d.ts.map +1 -0
- package/types/research/index.d.ts +8 -0
- package/types/research/index.d.ts.map +1 -0
- package/types/research/researchTime.d.ts +16 -0
- package/types/research/researchTime.d.ts.map +1 -0
- package/types/trades/crystal.d.ts +6 -0
- package/types/trades/crystal.d.ts.map +1 -0
- package/types/trades/deut.d.ts +6 -0
- package/types/trades/deut.d.ts.map +1 -0
- package/types/trades/index.d.ts +12 -0
- package/types/trades/index.d.ts.map +1 -0
- package/types/trades/metal.d.ts +6 -0
- package/types/trades/metal.d.ts.map +1 -0
- package/types/trades/parseRate.d.ts +2 -0
- package/types/trades/parseRate.d.ts.map +1 -0
- package/types/trades/utils.d.ts +2 -0
- package/types/trades/utils.d.ts.map +1 -0
- package/types/types.d.ts +449 -0
- 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
|
-
|
|
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
|
|
19
|
-
* @param {
|
|
20
|
-
* @param {number} targetLevel
|
|
21
|
-
* @returns {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|