poi-plugin-lbas-bis 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/i18n/en-US.json +56 -0
- package/i18n/ja-JP.json +56 -0
- package/i18n/zh-CN.json +56 -0
- package/i18n/zh-TW.json +56 -0
- package/index.js +558 -0
- package/package.json +40 -0
- package/src/air-power.js +183 -0
- package/src/damage.js +54 -0
- package/src/import-plan.js +31 -0
- package/src/optimizer.js +417 -0
- package/src/poi-data.js +154 -0
- package/src/simulator-calc.js +90 -0
- package/src/simulator-state.js +216 -0
- package/src/ui/BaseTable.js +134 -0
- package/src/ui/EnemyPanel.js +56 -0
- package/src/ui/OptimizerPanel.js +149 -0
- package/src/ui/SimulatorPanel.js +95 -0
- package/src/ui/WaveStatusTable.js +41 -0
package/src/air-power.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const AIR_STATES = {
|
|
4
|
+
loss: { key: 'loss', label: 'Loss', rank: 0 },
|
|
5
|
+
denial: { key: 'denial', label: 'Denial', rank: 1 },
|
|
6
|
+
parity: { key: 'parity', label: 'Parity', rank: 2 },
|
|
7
|
+
superiority: { key: 'superiority', label: 'Superiority', rank: 3 },
|
|
8
|
+
supremacy: { key: 'supremacy', label: 'Supremacy', rank: 4 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const FIGHTER_PROFICIENCY_BONUS = [0, 0, 2, 5, 9, 14, 14, 22];
|
|
12
|
+
const SEAPLANE_BOMBER_PROFICIENCY_BONUS = [0, 0, 1, 1, 1, 3, 3, 6];
|
|
13
|
+
const INTERNAL_PROFICIENCY_BONUS = [0, 1, 2.5, 4, 5.5, 7, 8.5, 12];
|
|
14
|
+
const RECON_COEFFICIENTS = new Map([
|
|
15
|
+
[311, 1.15],
|
|
16
|
+
[312, 1.18],
|
|
17
|
+
]);
|
|
18
|
+
const EXTENDER_MASTER_IDS = new Set([138, 178, 311, 312]);
|
|
19
|
+
const DEFAULT_LBAS_SLOT_SIZE = 18;
|
|
20
|
+
const RECON_SLOT_SIZE = 4;
|
|
21
|
+
|
|
22
|
+
function airStateFor(airPower, enemyAir) {
|
|
23
|
+
const enemy = Math.max(0, Number(enemyAir) || 0);
|
|
24
|
+
const value = Math.max(0, Math.floor(Number(airPower) || 0));
|
|
25
|
+
|
|
26
|
+
if (enemy === 0) {
|
|
27
|
+
return { ...AIR_STATES.supremacy, threshold: 0, margin: value };
|
|
28
|
+
}
|
|
29
|
+
if (value >= requiredAirForState(enemy, 'supremacy')) {
|
|
30
|
+
return {
|
|
31
|
+
...AIR_STATES.supremacy,
|
|
32
|
+
threshold: requiredAirForState(enemy, 'supremacy'),
|
|
33
|
+
margin: value - requiredAirForState(enemy, 'supremacy'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (value >= requiredAirForState(enemy, 'superiority')) {
|
|
37
|
+
return {
|
|
38
|
+
...AIR_STATES.superiority,
|
|
39
|
+
threshold: requiredAirForState(enemy, 'superiority'),
|
|
40
|
+
margin: value - requiredAirForState(enemy, 'superiority'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (value >= requiredAirForState(enemy, 'parity')) {
|
|
44
|
+
return {
|
|
45
|
+
...AIR_STATES.parity,
|
|
46
|
+
threshold: requiredAirForState(enemy, 'parity'),
|
|
47
|
+
margin: value - requiredAirForState(enemy, 'parity'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (value >= requiredAirForState(enemy, 'denial')) {
|
|
51
|
+
return {
|
|
52
|
+
...AIR_STATES.denial,
|
|
53
|
+
threshold: requiredAirForState(enemy, 'denial'),
|
|
54
|
+
margin: value - requiredAirForState(enemy, 'denial'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ...AIR_STATES.loss, threshold: 0, margin: value };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requiredAirForState(enemyAir, stateKey) {
|
|
61
|
+
const enemy = Math.max(0, Number(enemyAir) || 0);
|
|
62
|
+
|
|
63
|
+
if (enemy === 0) {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
switch (stateKey) {
|
|
67
|
+
case 'supremacy':
|
|
68
|
+
return enemy * 3;
|
|
69
|
+
case 'superiority':
|
|
70
|
+
return Math.ceil(enemy * 1.5);
|
|
71
|
+
case 'parity':
|
|
72
|
+
return Math.floor(enemy / 1.5) + 1;
|
|
73
|
+
case 'denial':
|
|
74
|
+
return Math.floor(enemy / 3) + 1;
|
|
75
|
+
case 'loss':
|
|
76
|
+
return 0;
|
|
77
|
+
default:
|
|
78
|
+
throw new Error(`Unknown air state: ${stateKey}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function calculateSlotAirPower(plane, slotSize) {
|
|
83
|
+
const size = Math.max(0, Number(plane.slotSize ?? slotSize ?? defaultSlotSizeForPlane(plane)) || 0);
|
|
84
|
+
const antiAir = Math.max(0, Number(plane.antiAir) || 0);
|
|
85
|
+
const intercept = Math.max(0, Number(plane.intercept) || 0);
|
|
86
|
+
const sortieAntiAir = antiAir + intercept * 1.5;
|
|
87
|
+
const improvedAntiAir = sortieAntiAir + improvementBonus(plane);
|
|
88
|
+
const proficiencyBonus = proficiencyBonusForPlane(plane);
|
|
89
|
+
|
|
90
|
+
return Math.floor(improvedAntiAir * Math.sqrt(size) + proficiencyBonus);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function calculateBaseAirPower(loadout, slotSize) {
|
|
94
|
+
const rawAirPower = loadout.reduce(
|
|
95
|
+
(total, plane) => total + calculateSlotAirPower(plane, slotSize),
|
|
96
|
+
0,
|
|
97
|
+
);
|
|
98
|
+
return Math.floor(rawAirPower * landReconCoefficient(loadout));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function calculateEffectiveRadius(loadout) {
|
|
102
|
+
if (!loadout.length) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const combatPlanes = loadout.filter((plane) => !isRangeExtender(plane));
|
|
107
|
+
const radiusSource = combatPlanes.length > 0 ? combatPlanes : loadout;
|
|
108
|
+
const naturalRadius = Math.min(...radiusSource.map((plane) => plane.radius || 0));
|
|
109
|
+
const extenderRadius = Math.max(
|
|
110
|
+
0,
|
|
111
|
+
...loadout.filter(isRangeExtender).map((plane) => plane.radius || 0),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (extenderRadius <= naturalRadius) {
|
|
115
|
+
return naturalRadius;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const extension = Math.min(
|
|
119
|
+
3,
|
|
120
|
+
Math.max(0, Math.round(Math.sqrt(extenderRadius - naturalRadius))),
|
|
121
|
+
);
|
|
122
|
+
return naturalRadius + extension;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function improvementBonus(plane) {
|
|
126
|
+
const level = Math.max(0, Number(plane.improvement) || 0);
|
|
127
|
+
|
|
128
|
+
if (isFighterLike(plane)) {
|
|
129
|
+
return level * 0.2;
|
|
130
|
+
}
|
|
131
|
+
if (plane.role === 'attacker' || plane.role === 'bomber') {
|
|
132
|
+
return Math.sqrt(level) * 0.5;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function proficiencyBonusForPlane(plane) {
|
|
138
|
+
const level = Math.max(0, Math.min(7, Number(plane.proficiency) || 0));
|
|
139
|
+
const internalBonus = Math.sqrt(INTERNAL_PROFICIENCY_BONUS[level]);
|
|
140
|
+
let visibleBonus = 0;
|
|
141
|
+
|
|
142
|
+
if (isFighterLike(plane)) {
|
|
143
|
+
visibleBonus = FIGHTER_PROFICIENCY_BONUS[level];
|
|
144
|
+
} else if (plane.role === 'seaplaneBomber') {
|
|
145
|
+
visibleBonus = SEAPLANE_BOMBER_PROFICIENCY_BONUS[level];
|
|
146
|
+
}
|
|
147
|
+
return visibleBonus + internalBonus;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function landReconCoefficient(loadout) {
|
|
151
|
+
return loadout.reduce((best, plane) => {
|
|
152
|
+
const coefficient = RECON_COEFFICIENTS.get(plane.masterId) || 1;
|
|
153
|
+
return Math.max(best, coefficient);
|
|
154
|
+
}, 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isFighterLike(plane) {
|
|
158
|
+
return (
|
|
159
|
+
plane.role === 'fighter' ||
|
|
160
|
+
plane.role === 'landFighter' ||
|
|
161
|
+
plane.role === 'interceptor' ||
|
|
162
|
+
plane.role === 'seaplaneFighter'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isRangeExtender(plane) {
|
|
167
|
+
return plane.role === 'recon' || plane.role === 'extender' || EXTENDER_MASTER_IDS.has(plane.masterId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function defaultSlotSizeForPlane(plane) {
|
|
171
|
+
return isRangeExtender(plane) ? RECON_SLOT_SIZE : DEFAULT_LBAS_SLOT_SIZE;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
AIR_STATES,
|
|
176
|
+
airStateFor,
|
|
177
|
+
calculateBaseAirPower,
|
|
178
|
+
calculateEffectiveRadius,
|
|
179
|
+
calculateSlotAirPower,
|
|
180
|
+
defaultSlotSizeForPlane,
|
|
181
|
+
isRangeExtender,
|
|
182
|
+
requiredAirForState,
|
|
183
|
+
};
|
package/src/damage.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LBAS_SLOT_SIZE = 18;
|
|
4
|
+
const DAMAGE_CAP = 220;
|
|
5
|
+
const LAND_BASED_RECON_DAMAGE_COEFFICIENTS = new Map([
|
|
6
|
+
[311, 1.125],
|
|
7
|
+
[312, 1.15],
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function calculatePlaneDamagePower(plane, options = {}) {
|
|
11
|
+
if (plane.role !== 'attacker') {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stat = antiShipAttackStat(plane);
|
|
16
|
+
if (stat <= 0) {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const slotSize = Number(options.slotSize) || DEFAULT_LBAS_SLOT_SIZE;
|
|
21
|
+
const reconModifier = Number(options.reconModifier) || 1;
|
|
22
|
+
const rawPower = stat * Math.sqrt(1.8 * slotSize) + 25;
|
|
23
|
+
|
|
24
|
+
if (plane.isLandBased) {
|
|
25
|
+
return Math.min(DAMAGE_CAP, Math.floor(Math.floor(rawPower * 0.8) * 1.8 * reconModifier));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Math.min(DAMAGE_CAP, Math.floor(Math.floor(rawPower) * reconModifier));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function calculateBaseDamagePower(loadout, options = {}) {
|
|
32
|
+
const reconModifier = Number(options.reconModifier) || landBasedReconDamageModifier(loadout);
|
|
33
|
+
return loadout.reduce(
|
|
34
|
+
(total, plane) => total + calculatePlaneDamagePower(plane, { ...options, reconModifier }),
|
|
35
|
+
0,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function antiShipAttackStat(plane) {
|
|
40
|
+
return Math.max(Number(plane.torpedo) || 0, Number(plane.bombing) || 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function landBasedReconDamageModifier(loadout) {
|
|
44
|
+
return loadout.reduce((best, plane) => {
|
|
45
|
+
const modifier = LAND_BASED_RECON_DAMAGE_COEFFICIENTS.get(plane.masterId) || 1;
|
|
46
|
+
return Math.max(best, modifier);
|
|
47
|
+
}, 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
calculateBaseDamagePower,
|
|
52
|
+
calculatePlaneDamagePower,
|
|
53
|
+
landBasedReconDamageModifier,
|
|
54
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeSimulatorState } = require('./simulator-state');
|
|
4
|
+
|
|
5
|
+
function applyPlanToSimulator(state, plan) {
|
|
6
|
+
const normalized = normalizeSimulatorState(state);
|
|
7
|
+
return normalizeSimulatorState({
|
|
8
|
+
...normalized,
|
|
9
|
+
bases: normalized.bases.map((base, baseIndex) => {
|
|
10
|
+
const plannedLoadout = plan?.bases?.[baseIndex]?.loadout || [];
|
|
11
|
+
return {
|
|
12
|
+
...base,
|
|
13
|
+
slots: base.slots.map((slot, slotIndex) => {
|
|
14
|
+
if (slot.locked) {
|
|
15
|
+
return slot;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
...slot,
|
|
19
|
+
plane: plannedLoadout[slotIndex] || null,
|
|
20
|
+
proficiency: null,
|
|
21
|
+
improvement: null,
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
applyPlanToSimulator,
|
|
31
|
+
};
|
package/src/optimizer.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
AIR_STATES,
|
|
5
|
+
airStateFor,
|
|
6
|
+
calculateBaseAirPower,
|
|
7
|
+
calculateEffectiveRadius,
|
|
8
|
+
requiredAirForState,
|
|
9
|
+
} = require('./air-power');
|
|
10
|
+
const { calculateBaseDamagePower } = require('./damage');
|
|
11
|
+
|
|
12
|
+
const SLOTS_PER_BASE = 4;
|
|
13
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
14
|
+
const MAX_BASE_CANDIDATES = 1200;
|
|
15
|
+
const WAVES_PER_BASE = 2;
|
|
16
|
+
|
|
17
|
+
function optimizeLoadouts(options) {
|
|
18
|
+
const {
|
|
19
|
+
equipment,
|
|
20
|
+
baseCount = 1,
|
|
21
|
+
targetRadius,
|
|
22
|
+
enemyAir,
|
|
23
|
+
targetStates = [],
|
|
24
|
+
lockedBases = [],
|
|
25
|
+
maxResults = DEFAULT_MAX_RESULTS,
|
|
26
|
+
} = options;
|
|
27
|
+
const messages = [];
|
|
28
|
+
const wantedBaseCount = Math.max(1, Math.min(3, Number(baseCount) || 1));
|
|
29
|
+
const waveTargets = normalizeWaveTargets(targetStates, wantedBaseCount);
|
|
30
|
+
const baseLocks = normalizeLockedBases(lockedBases, wantedBaseCount);
|
|
31
|
+
const candidatesByBase = baseLocks.map((lockedBase) =>
|
|
32
|
+
generateBaseCandidates(equipment, targetRadius, enemyAir, lockedBase.slots),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (candidatesByBase.some((candidates) => candidates.length === 0)) {
|
|
36
|
+
messages.push(`No candidate loadout can reach radius ${targetRadius}.`);
|
|
37
|
+
return { messages, results: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const results = combineBases({
|
|
41
|
+
candidatesByBase,
|
|
42
|
+
baseIndex: 0,
|
|
43
|
+
baseCount: wantedBaseCount,
|
|
44
|
+
enemyAir,
|
|
45
|
+
waveTargets,
|
|
46
|
+
usedIds: new Set(),
|
|
47
|
+
selected: [],
|
|
48
|
+
})
|
|
49
|
+
.map((bases) => summarizePlan(bases, enemyAir, waveTargets))
|
|
50
|
+
.filter((plan) => plan.fulfilled)
|
|
51
|
+
.sort(comparePlans)
|
|
52
|
+
.slice(0, maxResults);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
messages,
|
|
56
|
+
results,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function generateBaseCandidates(equipment, targetRadius, enemyAir, slotConstraints = []) {
|
|
61
|
+
const slots = normalizeSlotConstraints(slotConstraints);
|
|
62
|
+
const lockedLoadout = slots.map((slot) => slot.locked ? slot.plane : null).filter(Boolean);
|
|
63
|
+
if (hasDuplicateInstanceId(lockedLoadout)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lockedIds = new Set(lockedLoadout.map((plane) => plane.instanceId));
|
|
68
|
+
const pool = selectCandidatePool([...(equipment || []), ...lockedLoadout], targetRadius)
|
|
69
|
+
.filter((plane) => !lockedIds.has(plane.instanceId));
|
|
70
|
+
const unlockedSlots = slots
|
|
71
|
+
.map((slot, index) => ({ slot, index }))
|
|
72
|
+
.filter((entry) => !entry.slot.locked);
|
|
73
|
+
const combinations = [];
|
|
74
|
+
choose(pool, unlockedSlots.length, 0, [], combinations);
|
|
75
|
+
|
|
76
|
+
return combinations
|
|
77
|
+
.map((selectedPlanes) => buildLoadoutFromSlots(slots, unlockedSlots, selectedPlanes))
|
|
78
|
+
.filter((loadout) => loadout.length === SLOTS_PER_BASE && !hasDuplicateInstanceId(loadout))
|
|
79
|
+
.map((loadout) => summarizeCandidate(loadout, enemyAir))
|
|
80
|
+
.filter((candidate) => candidate.radius >= targetRadius)
|
|
81
|
+
.sort(compareCandidates)
|
|
82
|
+
.slice(0, MAX_BASE_CANDIDATES);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildLoadoutFromSlots(slots, unlockedSlots, selectedPlanes) {
|
|
86
|
+
const loadout = slots.map((slot) => slot.locked ? slot.plane : null);
|
|
87
|
+
unlockedSlots.forEach((entry, selectedIndex) => {
|
|
88
|
+
loadout[entry.index] = selectedPlanes[selectedIndex] || null;
|
|
89
|
+
});
|
|
90
|
+
return loadout.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function summarizeCandidate(loadout, enemyAir) {
|
|
94
|
+
const airPower = calculateBaseAirPower(loadout);
|
|
95
|
+
const radius = calculateEffectiveRadius(loadout);
|
|
96
|
+
const state = airStateFor(airPower, enemyAir);
|
|
97
|
+
const attackScore = loadout.reduce((total, plane) => total + planeAttackScore(plane), 0);
|
|
98
|
+
const damagePower = calculateBaseDamagePower(loadout);
|
|
99
|
+
const landBasedCount = loadout.filter((plane) => plane.isLandBased).length;
|
|
100
|
+
const landAttackerCount = loadout.filter((plane) => plane.isLandBased && plane.role === 'attacker').length;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
loadout,
|
|
104
|
+
airPower,
|
|
105
|
+
radius,
|
|
106
|
+
state,
|
|
107
|
+
attackScore,
|
|
108
|
+
damagePower,
|
|
109
|
+
landBasedCount,
|
|
110
|
+
landAttackerCount,
|
|
111
|
+
fighterCount: loadout.filter((plane) => plane.role.includes('fighter')).length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function selectCandidatePool(equipment, targetRadius) {
|
|
116
|
+
const unique = new Map();
|
|
117
|
+
for (const plane of equipment || []) {
|
|
118
|
+
if (!plane || plane.instanceId == null || unique.has(plane.instanceId)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
unique.set(plane.instanceId, plane);
|
|
122
|
+
}
|
|
123
|
+
const values = [...unique.values()];
|
|
124
|
+
const maxExtenderRadius = values
|
|
125
|
+
.filter((plane) => plane.role === 'recon')
|
|
126
|
+
.reduce((best, plane) => Math.max(best, Number(plane.radius) || 0), 0);
|
|
127
|
+
return values
|
|
128
|
+
.filter((plane) => canContributeToTargetRadiusWithExtenders(plane, targetRadius, maxExtenderRadius))
|
|
129
|
+
.sort((left, right) => equipmentSortScore(right, targetRadius) - equipmentSortScore(left, targetRadius))
|
|
130
|
+
.slice(0, 72);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function choose(items, count, start, current, output) {
|
|
134
|
+
if (current.length === count) {
|
|
135
|
+
output.push([...current]);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (let index = start; index <= items.length - (count - current.length); index += 1) {
|
|
140
|
+
current.push(items[index]);
|
|
141
|
+
choose(items, count, index + 1, current, output);
|
|
142
|
+
current.pop();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function combineBases(context) {
|
|
147
|
+
const {
|
|
148
|
+
candidatesByBase,
|
|
149
|
+
baseIndex,
|
|
150
|
+
baseCount,
|
|
151
|
+
enemyAir,
|
|
152
|
+
waveTargets,
|
|
153
|
+
usedIds,
|
|
154
|
+
selected,
|
|
155
|
+
} = context;
|
|
156
|
+
|
|
157
|
+
if (baseIndex === baseCount) {
|
|
158
|
+
return [[...selected]];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const targetState = targetStateForBase(waveTargets, baseIndex);
|
|
162
|
+
const requiredRank = AIR_STATES[targetState]?.rank ?? AIR_STATES.parity.rank;
|
|
163
|
+
const plans = [];
|
|
164
|
+
const sortedCandidates = candidatesByBase[baseIndex]
|
|
165
|
+
.filter((candidate) => candidate.state.rank >= requiredRank)
|
|
166
|
+
.sort((left, right) => compareCandidatesForTarget(left, right, enemyAir, targetState));
|
|
167
|
+
|
|
168
|
+
for (const candidate of sortedCandidates) {
|
|
169
|
+
if (overlaps(candidate, usedIds)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const plane of candidate.loadout) {
|
|
174
|
+
usedIds.add(plane.instanceId);
|
|
175
|
+
}
|
|
176
|
+
selected.push(candidate);
|
|
177
|
+
|
|
178
|
+
plans.push(
|
|
179
|
+
...combineBases({
|
|
180
|
+
candidatesByBase,
|
|
181
|
+
baseIndex: baseIndex + 1,
|
|
182
|
+
baseCount,
|
|
183
|
+
enemyAir,
|
|
184
|
+
waveTargets,
|
|
185
|
+
usedIds,
|
|
186
|
+
selected,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
selected.pop();
|
|
191
|
+
for (const plane of candidate.loadout) {
|
|
192
|
+
usedIds.delete(plane.instanceId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (plans.length >= DEFAULT_MAX_RESULTS * 8) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return plans;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function summarizePlan(bases, enemyAir, waveTargets) {
|
|
204
|
+
const baseSummaries = bases.map((candidate, index) => {
|
|
205
|
+
const targetState = targetStateForBase(waveTargets, index);
|
|
206
|
+
return {
|
|
207
|
+
...candidate,
|
|
208
|
+
targetState,
|
|
209
|
+
marginToTarget: candidate.airPower - requiredAirForTarget(enemyAir, targetState),
|
|
210
|
+
minimumProficiency: minimumProficiencyForTarget(candidate.loadout, enemyAir, targetState),
|
|
211
|
+
missingEquipment: summarizeMissingEquipment(candidate.loadout),
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
const fulfilled = baseSummaries.every(
|
|
215
|
+
(base) => base.state.rank >= (AIR_STATES[base.targetState]?.rank ?? AIR_STATES.parity.rank),
|
|
216
|
+
);
|
|
217
|
+
const minimumProficiencyValues = baseSummaries
|
|
218
|
+
.map((base) => base.minimumProficiency)
|
|
219
|
+
.filter((value) => value != null);
|
|
220
|
+
return {
|
|
221
|
+
fulfilled,
|
|
222
|
+
bases: baseSummaries,
|
|
223
|
+
waves: buildWaveSummaries(baseSummaries, enemyAir, waveTargets),
|
|
224
|
+
missingEquipment: summarizeMissingEquipment(baseSummaries.flatMap((base) => base.loadout)),
|
|
225
|
+
minimumProficiency: minimumProficiencyValues.length
|
|
226
|
+
? Math.max(...minimumProficiencyValues)
|
|
227
|
+
: null,
|
|
228
|
+
totalAirPower: baseSummaries.reduce((total, base) => total + base.airPower, 0),
|
|
229
|
+
totalAttackScore: baseSummaries.reduce((total, base) => total + base.attackScore, 0),
|
|
230
|
+
totalDamagePower: baseSummaries.reduce((total, base) => total + base.damagePower, 0),
|
|
231
|
+
worstMargin: Math.min(...baseSummaries.map((base) => base.marginToTarget)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function overlaps(candidate, usedIds) {
|
|
236
|
+
return candidate.loadout.some((plane) => usedIds.has(plane.instanceId));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeLockedBases(lockedBases, baseCount) {
|
|
240
|
+
return Array.from({ length: baseCount }, (_, baseIndex) => ({
|
|
241
|
+
slots: normalizeSlotConstraints(lockedBases?.[baseIndex]?.slots),
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeSlotConstraints(slotConstraints = []) {
|
|
246
|
+
return Array.from({ length: SLOTS_PER_BASE }, (_, slotIndex) => {
|
|
247
|
+
const slot = slotConstraints[slotIndex] || {};
|
|
248
|
+
return {
|
|
249
|
+
plane: slot.plane || null,
|
|
250
|
+
locked: Boolean(slot.locked && slot.plane),
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hasDuplicateInstanceId(loadout) {
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
for (const plane of loadout) {
|
|
258
|
+
if (!plane || plane.instanceId == null) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (seen.has(plane.instanceId)) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
seen.add(plane.instanceId);
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function compareCandidates(left, right) {
|
|
270
|
+
return (
|
|
271
|
+
right.landAttackerCount - left.landAttackerCount ||
|
|
272
|
+
right.damagePower - left.damagePower ||
|
|
273
|
+
right.landBasedCount - left.landBasedCount ||
|
|
274
|
+
right.attackScore - left.attackScore ||
|
|
275
|
+
left.fighterCount - right.fighterCount ||
|
|
276
|
+
right.airPower - left.airPower ||
|
|
277
|
+
right.state.rank - left.state.rank ||
|
|
278
|
+
0
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function compareCandidatesForTarget(left, right, enemyAir, targetState) {
|
|
283
|
+
const requiredAir = requiredAirForTarget(enemyAir, targetState);
|
|
284
|
+
const leftWaste = Math.max(0, left.airPower - requiredAir);
|
|
285
|
+
const rightWaste = Math.max(0, right.airPower - requiredAir);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
right.landAttackerCount - left.landAttackerCount ||
|
|
289
|
+
right.damagePower - left.damagePower ||
|
|
290
|
+
right.landBasedCount - left.landBasedCount ||
|
|
291
|
+
right.attackScore - left.attackScore ||
|
|
292
|
+
leftWaste - rightWaste ||
|
|
293
|
+
left.fighterCount - right.fighterCount
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function comparePlans(left, right) {
|
|
298
|
+
return (
|
|
299
|
+
Number(right.fulfilled) - Number(left.fulfilled) ||
|
|
300
|
+
right.totalDamagePower - left.totalDamagePower ||
|
|
301
|
+
right.totalAttackScore - left.totalAttackScore ||
|
|
302
|
+
right.worstMargin - left.worstMargin ||
|
|
303
|
+
right.totalAirPower - left.totalAirPower
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function canContributeToTargetRadius(plane, targetRadius) {
|
|
308
|
+
const radius = Number(plane.radius) || 0;
|
|
309
|
+
if (radius <= 0) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (radius >= targetRadius) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return plane.role === 'recon' && radius >= Math.max(1, targetRadius - 3);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function canContributeToTargetRadiusWithExtenders(plane, targetRadius, maxExtenderRadius) {
|
|
319
|
+
const radius = Number(plane.radius) || 0;
|
|
320
|
+
if (canContributeToTargetRadius(plane, targetRadius)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (radius <= 0 || plane.role === 'recon') {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
if (maxExtenderRadius <= radius) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
const extension = Math.min(3, Math.max(0, Math.round(Math.sqrt(maxExtenderRadius - radius))));
|
|
330
|
+
return radius + extension >= targetRadius;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function equipmentSortScore(plane, targetRadius) {
|
|
334
|
+
const reachesTarget = (Number(plane.radius) || 0) >= targetRadius;
|
|
335
|
+
return (
|
|
336
|
+
(reachesTarget ? 10000 : 0) +
|
|
337
|
+
(plane.isLandBased ? 1000 : 0) +
|
|
338
|
+
(plane.role === 'attacker' ? 700 : 0) +
|
|
339
|
+
(plane.role === 'recon' ? 350 : 0) +
|
|
340
|
+
(plane.antiAir || 0) * 10 +
|
|
341
|
+
(plane.intercept || 0) * 8 +
|
|
342
|
+
planeAttackScore(plane) +
|
|
343
|
+
(plane.radius || 0)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function planeAttackScore(plane) {
|
|
348
|
+
return (plane.torpedo || 0) + (plane.bombing || 0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function requiredAirForTarget(enemyAir, targetState) {
|
|
352
|
+
return requiredAirForState(enemyAir, targetState);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function normalizeWaveTargets(targetStates, baseCount) {
|
|
356
|
+
const states = (Array.isArray(targetStates) ? targetStates : [])
|
|
357
|
+
.filter((state) => AIR_STATES[state]);
|
|
358
|
+
const fallback = states[0] || 'parity';
|
|
359
|
+
return Array.from({ length: baseCount * WAVES_PER_BASE }, (_, index) => states[index] || fallback);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function minimumProficiencyForTarget(loadout, enemyAir, targetState) {
|
|
363
|
+
const requiredRank = AIR_STATES[targetState]?.rank ?? AIR_STATES.parity.rank;
|
|
364
|
+
for (let level = 0; level <= 7; level += 1) {
|
|
365
|
+
const adjustedLoadout = loadout.map((plane) => ({ ...plane, proficiency: level }));
|
|
366
|
+
const airPower = calculateBaseAirPower(adjustedLoadout);
|
|
367
|
+
if (airStateFor(airPower, enemyAir).rank >= requiredRank) {
|
|
368
|
+
return level;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function summarizeMissingEquipment(loadout) {
|
|
375
|
+
const missing = new Map();
|
|
376
|
+
for (const plane of loadout) {
|
|
377
|
+
if (!plane.missing && plane.available !== false) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const key = plane.masterId;
|
|
381
|
+
const current = missing.get(key) || {
|
|
382
|
+
masterId: plane.masterId,
|
|
383
|
+
name: plane.name,
|
|
384
|
+
count: 0,
|
|
385
|
+
};
|
|
386
|
+
current.count += 1;
|
|
387
|
+
missing.set(key, current);
|
|
388
|
+
}
|
|
389
|
+
return [...missing.values()].sort((left, right) => left.masterId - right.masterId);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function targetStateForBase(waveTargets, baseIndex) {
|
|
393
|
+
const first = waveTargets[baseIndex * WAVES_PER_BASE] || waveTargets[0] || 'parity';
|
|
394
|
+
const second = waveTargets[baseIndex * WAVES_PER_BASE + 1] || first;
|
|
395
|
+
return [first, second].sort((left, right) => AIR_STATES[right].rank - AIR_STATES[left].rank)[0];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function buildWaveSummaries(baseSummaries, enemyAir, waveTargets) {
|
|
399
|
+
return waveTargets.map((targetState, waveIndex) => {
|
|
400
|
+
const baseIndex = Math.floor(waveIndex / WAVES_PER_BASE);
|
|
401
|
+
const base = baseSummaries[baseIndex];
|
|
402
|
+
return {
|
|
403
|
+
waveIndex,
|
|
404
|
+
baseIndex,
|
|
405
|
+
targetState,
|
|
406
|
+
airPower: base.airPower,
|
|
407
|
+
state: airStateFor(base.airPower, enemyAir),
|
|
408
|
+
marginToTarget: base.airPower - requiredAirForTarget(enemyAir, targetState),
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
generateBaseCandidates,
|
|
415
|
+
normalizeWaveTargets,
|
|
416
|
+
optimizeLoadouts,
|
|
417
|
+
};
|