diff --git a/src/api-wrapper/api.ts b/src/api-wrapper/api.ts index f70671e..c875b6f 100644 --- a/src/api-wrapper/api.ts +++ b/src/api-wrapper/api.ts @@ -1,15 +1,20 @@ import { - WarInfo, - Status, - MergedPlanetData, - MergedCampaignData, - MergedPlanetEventData, + AdditionalPlanetInfo, ApiData, + ArmorItem, Assignment, + BoosterItem, + GrenadeItem, + Items, + MergedCampaignData, + MergedPlanetData, + MergedPlanetEventData, NewsFeedItem, PlanetStats, + Status, StoreRotation, - AdditionalPlanetInfo, + WarInfo, + WeaponItem, } from './types'; import {getFactionName, getPlanetEventType, getPlanetName} from './mapping'; import {writeFileSync} from 'fs'; @@ -22,15 +27,15 @@ import {logger} from '../handlers'; const CHATS_URL = 'https://api.diveharder.com/v1/all'; const CHATS_URL_RAW = 'https://api.diveharder.com/raw/all'; const FALLBACK_URL = 'https://api.helldivers2.dev/raw/api'; -const {IDENTIFIER} = config; -const {CONTACT} = config; + +const {IDENTIFIER, CONTACT, DEALLOC_TOKEN} = config; const apiClient = axios.create({ headers: { 'Accept-Language': 'en-us', 'User-Agent': IDENTIFIER, 'X-Super-Client': IDENTIFIER, - 'X-Super-Contact': CONTACT + 'X-Super-Contact': CONTACT, }, }); @@ -110,6 +115,19 @@ export async function getData() { let planetStats: PlanetStats; let newsFeed: NewsFeedItem[]; let storeRotation: StoreRotation | undefined = undefined; + const gameItems: Items = { + armor: { + Body: [], + Cloak: [], + Head: [], + }, + boosters: [], + weapons: { + secondaries: [], + grenades: [], + primaries: [], + }, + }; let additionalPlanetInfo: AdditionalPlanetInfo | undefined = undefined; try { @@ -138,10 +156,54 @@ export async function getData() { newsFeed.sort((a, b) => b.published - a.published); storeRotation = chatsAPI['store_rotation'] as StoreRotation; additionalPlanetInfo = chatsAPI['planets'] as AdditionalPlanetInfo; + + const chatsItems = chatsAPI['items']; + if (chatsItems['armor']) { + for (const [id, armor] of Object.entries(chatsItems['armor'])) { + const typedArmor = armor as Omit; + const {slot} = typedArmor; + gameItems.armor[slot as 'Head' | 'Body' | 'Cloak'].push({ + id, + ...typedArmor, + }); + } + } + if (chatsItems['weapons']) { + const {primaries, secondaries, grenades} = chatsItems['weapons']; + for (const [id, w] of Object.entries(primaries)) { + const weapon = w as Omit; + gameItems.weapons['primaries'].push({ + id, + ...weapon, + }); + } + for (const [id, w] of Object.entries(secondaries)) { + const weapon = w as Omit; + gameItems.weapons['secondaries'].push({ + id, + ...weapon, + }); + } + for (const [id, grenade] of Object.entries(grenades)) { + gameItems.weapons['grenades'].push({ + id, + ...(grenade as Omit), + }); + } + } + if (chatsItems['boosters']) { + for (const [id, boosters] of Object.entries(chatsItems['boosters'])) { + gameItems.boosters.push({ + id, + ...(boosters as Omit), + }); + } + } } else { - logger.error('Fallback to dealloc APIs', {type: 'API'}); - // create a fallback API client + logger.error('Fallback to dealloc API', {type: 'API'}); + // fallback to dealloc API with delay between calls to avoid rate limits (429s) apiClient.defaults.baseURL = FALLBACK_URL; + apiClient.defaults.headers['Authorization'] = `Bearer ${DEALLOC_TOKEN}`; const {id} = await (await apiClient.get('/WarSeason/current/WarID')).data; warInfo = await (await apiClient.get(`/WarSeason/${id}/WarInfo`)).data; status = await (await apiClient.get(`/WarSeason/${id}/Status`)).data; @@ -248,8 +310,37 @@ export async function getData() { }; if (additionalPlanetInfo) data.additionalPlanetInfo = additionalPlanetInfo; if (storeRotation) data.SuperStore = storeRotation; + if (gameItems.weapons) data.Items = gameItems; // just check one field to see if the data is there writeFileSync('data.json', JSON.stringify(data, null, 2)); + + // update mapped names + mappedNames.planets = data.Planets.map(x => x.name); + mappedNames.campaignPlanets = data.Campaigns.map(x => x.planetName); + if (data.Items) { + // items includes armours, weapons and boosters, so we need to map them all + mappedNames.armors = [ + ...data.Items.armor.Body.map(a => `${a.name} (${a.type} ${a.slot})`), + ...data.Items.armor.Cloak.map(a => `${a.name} (${a.type} ${a.slot})`), + ...data.Items.armor.Head.map(a => `${a.name} (${a.type} ${a.slot})`), + ]; + // armor may have duplicates for variants (skins?), so we need to deduplicate + mappedNames.armors = mappedNames.armors.filter((armor, index) => { + return ( + index === + mappedNames.armors.findIndex(obj => { + return JSON.stringify(obj) === JSON.stringify(armor); + }) + ); + }); + mappedNames.weapons = [ + ...data.Items.weapons.primaries.map(w => w.name), + ...data.Items.weapons.secondaries.map(w => w.name), + ]; + mappedNames.grenades = data.Items.weapons.grenades.map(w => w.name); + mappedNames.boosters = data.Items.boosters.map(b => b.name); + } + return data; } @@ -258,10 +349,20 @@ export const mappedNames: { planets: string[]; campaignPlanets: string[]; sectors: string[]; + // items autocomplete + armors: string[]; + weapons: string[]; + grenades: string[]; + boosters: string[]; } = { factions: [], planets: [], campaignPlanets: [], sectors: [], + armors: [], + weapons: [], + grenades: [], + boosters: [], }; + export const planetNames = getAllPlanets().map(p => p.name); diff --git a/src/api-wrapper/mapping/factions.json b/src/api-wrapper/mapping/factions.json index 33a6356..15f52b9 100644 --- a/src/api-wrapper/mapping/factions.json +++ b/src/api-wrapper/mapping/factions.json @@ -1,5 +1,6 @@ { + "0": "Enemies", "1": "Humans", "2": "Terminids", "3": "Automaton" -} \ No newline at end of file +} diff --git a/src/api-wrapper/types.ts b/src/api-wrapper/types.ts index f229a21..4c0a42d 100644 --- a/src/api-wrapper/types.ts +++ b/src/api-wrapper/types.ts @@ -359,6 +359,79 @@ export type PlanetStats = { planets_stats: PlanetStatsItem[]; }; +export type ArmorItem = { + id: string; + name: string; + description: string; + type: 'Light' | 'Medium' | 'Heavy'; + slot: 'Head' | 'Body' | 'Cloak'; + armor_rating: number; + speed: number; + stamina_regen: number; + passive: { + name: string; + description: string; + }; +}; + +export type WeaponItem = { + id: string; + name: string; + description: string; + type: 'Primary' | 'Secondary'; + damage: number; + capacity: number; + recoil: number; + fire_rate: number; + fire_mode: string[]; + traits: string[]; +}; + +export type GrenadeItem = { + id: string; + name: string; + description: string; + damage: number; + penetration: number; + outer_radius: number; + fuse_time: number; +}; + +export type BoosterItem = { + id: string; + name: string; + description: string; +}; + +export type Items = { + armor: { + Head: ArmorItem[]; + Body: ArmorItem[]; + Cloak: ArmorItem[]; + }; + weapons: { + primaries: WeaponItem[]; + secondaries: WeaponItem[]; + grenades: GrenadeItem[]; + }; + boosters: BoosterItem[]; +}; + +export type WarbondItem = { + name: string; + mix_id: string; + medal_cost: number; +}; + +export type Warbond = { + [key: string]: { + medals_to_unlock: number; + items: { + [key: string]: WarbondItem; + }; + }; +}; + export type ApiData = { WarInfo: WarInfo; Status: Status; @@ -373,6 +446,13 @@ export type ApiData = { PlanetAttacks: {source: string; target: string}[]; Events: GlobalEvent[]; SuperStore?: StoreRotation; + Items?: Items; + Warbonds?: { + helldivers_mobilize: Warbond; + steeled_veterans: Warbond; + cutting_edge: Warbond; + democratic_detonation: Warbond; + }; Players: { [key in Faction]: number; }; diff --git a/src/commands/index.ts b/src/commands/index.ts index 1dbb61c..7b444e5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -10,6 +10,7 @@ import discord from './discord'; import dispatches from './dispatches'; import events from './events'; import history from './history'; +import items from './items'; import map from './map'; import steam from './steam'; import planet from './planet'; @@ -27,6 +28,7 @@ const commandList: Command[] = [ discord, dispatches, history, + items, map, // steam, // wiki, @@ -60,6 +62,7 @@ const presenceCmds = Object.keys(commandHash) // commands to offer planet autocomplete suggestions for const planetAutoCmds = ['planet', 'map']; const campaignAutoCmds = ['campaign']; +const itemAutoCmds = ['items']; // commands to not defer/suggestion etc. instead provide a modal for further input const modalCmds: string[] = []; @@ -87,5 +90,6 @@ export { ephemeralCmds, planetAutoCmds, campaignAutoCmds, + itemAutoCmds, wikiCmd, }; diff --git a/src/commands/items.ts b/src/commands/items.ts new file mode 100644 index 0000000..551df70 --- /dev/null +++ b/src/commands/items.ts @@ -0,0 +1,219 @@ +import { + CommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import {Command} from '../interfaces'; +import {FOOTER_MESSAGE} from './_components'; +import { + ArmorItem, + BoosterItem, + data, + GrenadeItem, + WeaponItem, +} from '../api-wrapper'; +import {logger} from '../handlers'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('items') + .setDescription('Look up any in-game Helldivers 2 item') + .addSubcommand(subcommand => + subcommand + .setName('armor') + .setDescription('Look up any in-game Helldivers 2 armor') + .addStringOption(option => + option + .setName('item') + .setDescription('Name of the armor') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('weapon') + .setDescription('Look up any in-game Helldivers 2 weapon') + .addStringOption(option => + option + .setName('item') + .setDescription('Name of the weapon') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('grenade') + .setDescription('Look up any in-game Helldivers 2 grenade') + .addStringOption(option => + option + .setName('item') + .setDescription('Name of the grenade') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('booster') + .setDescription('Look up any in-game Helldivers 2 booster') + .addStringOption(option => + option + .setName('item') + .setDescription('Name of the booster') + .setRequired(true) + .setAutocomplete(true) + ) + ), + + run: async interaction => { + const item = interaction.options.get('item', true).value as string; + const subCmd = interaction.options.data[0].name; + + const {Items} = data; + if (!Items) { + await interaction.editReply({ + content: 'Items data is not available', + }); + return; + } + + let foundItems: ArmorItem[] | WeaponItem[] | GrenadeItem[] | BoosterItem[] = + []; + if (subCmd === 'armor') { + const armors = [ + ...Items.armor.Cloak, + ...Items.armor.Head, + ...Items.armor.Body, + ]; + const foundArmors = armors.filter( + a => `${a.name} (${a.type} ${a.slot})` === item + ); + // deduplicate armours + foundItems = foundArmors.filter((armor, index) => { + return ( + index === + foundArmors.findIndex(obj => { + return JSON.stringify(obj) === JSON.stringify(armor); + }) + ); + }); + } else if (subCmd === 'weapon') { + const weapons = [ + ...Items.weapons.primaries, + ...Items.weapons.secondaries, + ]; + foundItems = weapons.filter(weapon => weapon.name === item); + } else if (subCmd === 'grenade') { + const grenades = Items.weapons.grenades; + foundItems = grenades.filter(grenade => grenade.name === item); + } else if (subCmd === 'booster') { + const boosters = Items.boosters; + foundItems = boosters.filter(booster => booster.name === item); + } + + if (!foundItems) { + await interaction.editReply({ + content: `Item not found: ${item}`, + }); + return; + } + + const embeds = foundItems.map(foundItem => { + const embed = new EmbedBuilder() + .setTitle(foundItem.name) + .setDescription(foundItem.description); + if ('armor_rating' in foundItem) { + // override title for armour + embed.setTitle( + `${foundItem.name} (${foundItem.type} ${foundItem.slot})` + ); + embed.addFields( + { + name: 'Armor Rating', + value: `${foundItem.armor_rating}`, + inline: true, + }, + { + name: 'Speed', + value: `${foundItem.speed}`, + inline: true, + }, + { + name: 'Stamina Regen', + value: `${foundItem.stamina_regen}`, + inline: true, + }, + { + name: `Passive: **${foundItem.passive.name}**`, + value: foundItem.passive.description, + inline: false, + } + ); + } else if ('fire_rate' in foundItem) { + embed.addFields( + { + name: 'Damage', + value: `${foundItem.damage}`, + inline: true, + }, + { + name: 'Capacity', + value: `${foundItem.capacity}`, + inline: true, + }, + { + name: 'Recoil', + value: `${foundItem.recoil}`, + inline: true, + }, + { + name: 'Fire Rate', + value: `${foundItem.fire_rate}`, + inline: true, + }, + { + name: 'Fire Mode', + value: foundItem.fire_mode.join('\n'), + inline: false, + }, + { + name: 'Traits', + value: foundItem.traits.join('\n'), + inline: false, + } + ); + } else if ('fuse_time' in foundItem) { + embed.addFields( + { + name: 'Damage', + value: `${foundItem.damage}`, + inline: true, + }, + { + name: 'Penetration', + value: `${foundItem.penetration}`, + inline: true, + }, + { + name: 'Outer Radius', + value: `${foundItem.outer_radius}`, + inline: true, + }, + { + name: 'Fuse Time', + value: `${foundItem.fuse_time}`, + inline: true, + } + ); + } + return embed; + }); + await interaction.editReply({ + embeds: embeds, + }); + }, +}; + +export default command; diff --git a/src/commands/superstore.ts b/src/commands/superstore.ts index 4b97d33..a18c9dd 100644 --- a/src/commands/superstore.ts +++ b/src/commands/superstore.ts @@ -27,34 +27,45 @@ const command: Command = { } const embeds: EmbedBuilder[] = []; for (const item of SuperStore.items) { - const embed = new EmbedBuilder() - .setTitle( - `${item.store_cost} ${sc_emoji} │ ${item.name} (${item.type} ${item.slot})` - ) - .setDescription(item.description) - .addFields( - { - name: 'Armour', - value: `${item.armor_rating}`, - inline: true, - }, - { - name: 'Speed', - value: `${item.speed}`, - inline: true, - }, - { - name: 'Stamina Regen', - value: `${item.stamina_regen}`, - inline: true, - }, - { - name: `Passive: ${item.passive.name}`, - value: item.passive.description, - inline: false, - } + if (item.name === 'Unmapped' || !('store_cost' in item)) { + embeds.push( + new EmbedBuilder() + .setTitle('Unmapped item') + .setDescription( + "This item's information is not available yet! Please try again later." + ) + ); + } else { + embeds.push( + new EmbedBuilder() + .setTitle( + `${item.store_cost} ${sc_emoji} │ ${item.name} (${item.type} ${item.slot})` + ) + .setDescription(item.description) + .addFields( + { + name: 'Armour', + value: `${item.armor_rating}`, + inline: true, + }, + { + name: 'Speed', + value: `${item.speed}`, + inline: true, + }, + { + name: 'Stamina Regen', + value: `${item.stamina_regen}`, + inline: true, + }, + { + name: `Passive: ${item.passive.name}`, + value: item.passive.description, + inline: false, + } + ) ); - embeds.push(embed); + } } // add a final embed for the expiration time diff --git a/src/config.ts b/src/config.ts index 319758a..2e6d563 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,7 +10,7 @@ const configObj: Record = { // Cron job intervals PERSISTENT_MESSAGE_INTERVAL: '*/30 * * * *', // every 30 minutes - API_UPDATE_INTERVAL: '*/20 * * * * *', // every 20 seconds + API_UPDATE_INTERVAL: '*/30 * * * * *', // every 20 seconds STATUS_UPDATE_INTERVAL: '*/3 * * * * *', // every 3 seconds DB_DATA_INTERVAL: '0 * * * *', // every 1 hour COMPARE_INTERVAL: '*/20 * * * * *', // every 20 seconds @@ -34,6 +34,9 @@ const configObj: Record = { GITHUB_LINK: 'https://github.com/helldivers-2/discord-bot', HD_COMPANION_LINK: 'https://helldiverscompanion.com/', + // API config + DEALLOC_TOKEN: process.env.DEALLOC_TOKEN, + // Project info VERSION: version, IDENTIFIER: 'HellComBot' + (isProd ? '' : 'Dev') + `/v${version}`, diff --git a/src/events/onInteraction.ts b/src/events/onInteraction.ts index 1d241f4..049b7c9 100644 --- a/src/events/onInteraction.ts +++ b/src/events/onInteraction.ts @@ -3,6 +3,7 @@ import { campaignAutoCmds, commandHash, ephemeralCmds, + itemAutoCmds, modalCmds, ownerCmds, planetAutoCmds, @@ -47,6 +48,49 @@ const onInteraction = async (interaction: Interaction) => { .map(choice => ({name: choice, value: choice})) ); } + if (itemAutoCmds.includes(interaction.commandName)) { + const subCmd = interaction.options.data[0].name; + if (subCmd === 'armor') + await interaction.respond( + search( + interaction.options.getFocused(), + mappedNames.armors, + searchOpts + ) + .map(result => result.target) + .map(choice => ({name: choice, value: choice})) + ); + else if (subCmd === 'weapon') + await interaction.respond( + search( + interaction.options.getFocused(), + mappedNames.weapons, + searchOpts + ) + .map(result => result.target) + .map(choice => ({name: choice, value: choice})) + ); + else if (subCmd === 'grenade') + await interaction.respond( + search( + interaction.options.getFocused(), + mappedNames.grenades, + searchOpts + ) + .map(result => result.target) + .map(choice => ({name: choice, value: choice})) + ); + else if (subCmd === 'booster') + await interaction.respond( + search( + interaction.options.getFocused(), + mappedNames.boosters, + searchOpts + ) + .map(result => result.target) + .map(choice => ({name: choice, value: choice})) + ); + } // for future use } else if (interaction.isModalSubmit()) { // for future use