From 8d6342bdcf05be36988084d1e9b067af2d9bd4c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Moschkin Date: Wed, 1 Nov 2023 00:32:06 -0400 Subject: [PATCH] Add 'Quip' Verb for Quipped crew. --- src/commands/cheapestfffe.ts | 2 +- src/commands/index.ts | 2 + src/commands/quip.ts | 217 +++++++++++++++++++++++++++++++++++ src/datacore/equipment.ts | 22 +++- src/datacore/player.ts | 5 +- src/utils/config.ts | 2 +- src/utils/crew.ts | 64 ++++++++--- src/utils/definitions.d.ts | 8 ++ src/utils/items.ts | 63 ++++++++++ 9 files changed, 360 insertions(+), 25 deletions(-) create mode 100644 src/commands/quip.ts diff --git a/src/commands/cheapestfffe.ts b/src/commands/cheapestfffe.ts index a718773..f9642ba 100644 --- a/src/commands/cheapestfffe.ts +++ b/src/commands/cheapestfffe.ts @@ -106,7 +106,7 @@ async function asyncHandler( } }).sort((a: any, b: any) => { let r = 0; - if (!r) r = (b.rarity/b.max_rarity) - (a.rarity/a.max_rarity); + //if (!r) r = (b.rarity/b.max_rarity) - (a.rarity/a.max_rarity); if (!r) r = a.requiredChronCost - b.requiredChronCost; return r; }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 96bf9ff..519e0cb 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -14,6 +14,7 @@ import { ProfileCommand } from './profile'; import { CrewNeedCommand } from './crewneed'; import { OffersCommand } from './offers'; import { CheapestFFFECommand } from './cheapestfffe'; +import { QuipmentCommand } from './quip'; export let Commands: Definitions.Command[] = [ AssociateCommand, @@ -32,4 +33,5 @@ export let Commands: Definitions.Command[] = [ SearchCommand, StatsCommand, VoyTimeCommand, + QuipmentCommand ]; diff --git a/src/commands/quip.ts b/src/commands/quip.ts new file mode 100644 index 0000000..dad3a94 --- /dev/null +++ b/src/commands/quip.ts @@ -0,0 +1,217 @@ +import { Message, EmbedBuilder, ApplicationCommandOptionType } from 'discord.js'; +import yargs from 'yargs'; + +import { DCData } from '../data/DCData'; +import { formatSources, formatRecipe, appelate, getItemBonuses } from '../utils/items'; +import { colorFromRarity, formatCollectionName, formatCurrentStatLine, formatSkillsStatsWithEmotes, formatStatLine } from '../utils/crew'; +import { getEmoteOrString, sendAndCache } from '../utils/discord'; +import CONFIG from '../utils/config'; +import { applyCrewBuffs, loadFullProfile, loadProfile, toTimestamp, userFromMessage } from '../utils/profile'; +import { getNeededItems } from '../utils/equipment'; +import { PlayerCrew } from '../datacore/player'; +import { EquipmentItem } from '../datacore/equipment'; +import { rarityLabels } from '../datacore/game-elements'; + +function bonusName(bonus: string) { + let cfg = CONFIG.STATS_CONFIG[Number.parseInt(bonus)]; + if (cfg) { + return `${CONFIG.SKILLS[cfg.skill as Definitions.SkillName]} ${cfg.stat}`; + } else { + return `*unknown (${bonus})*`; + } +} + +async function asyncHandler( + message: Message, + crewman?: string +) { + // This is just to break up the flow and make sure any exceptions end up in the .catch, not thrown during yargs command execution + await new Promise(resolve => setImmediate(() => resolve())); + + let user = await userFromMessage(message); + let settings = user?.profiles[0] ? await loadProfile(user.profiles[0].dbid) : null; + let profile = user?.profiles[0] ? loadFullProfile(user.profiles[0].dbid) : null; + if (!user || !profile) { + sendAndCache(message, "Sorry, I couldn't find an associated profile for your user.") + return; + } + if (crewman?.length) { + crewman = crewman.toLowerCase().trim(); + } + + let botCrew = DCData.getBotCrew(); + let quipment = DCData.getItems().filter((item: Definitions.Item) => item.type === 15 || item.type === 14); + + let profileCrew = profile.player.character.crew; + let profileItems = profile.player.character.items; + let quippedCrew = profileCrew.filter((c: PlayerCrew) => { + if (!!c.kwipment?.length) { + if (crewman?.length) { + let bcrew = botCrew.find(f => f.symbol===c.symbol); + if (!bcrew) return false; + if (!bcrew?.name.toLowerCase().trim().includes(crewman)) return false; + } + c.kwipment_items = (c.kwipment.map(kw => quipment.find(q => q.kwipment_id?.toString() === kw[1].toString()))?.filter(chk => !!chk) ?? []) as Definitions.Item[]; + return true; + } + else { + return false; + } + }) + .map((crew: PlayerCrew) => { + let matched = botCrew.find((crew) => { + return crew.symbol === crew.symbol + }) as Definitions.BotCrew; + crew = JSON.parse(JSON.stringify(crew)); + crew.name = matched?.name ?? crew.name; + crew.bigbook_tier = matched?.bigbook_tier; + crew.date_added = new Date(crew.date_added); + if (settings) crew.base_skills = applyCrewBuffs(crew.base_skills, settings.buffConfig); + return crew; + }) + .sort((a: PlayerCrew, b: PlayerCrew) => { + let r = 0; + if (!r) r = (b.kwipment_items?.length ?? 0) - (a.kwipment_items?.length ?? 0); + if (!r) r = (b.max_rarity - a.max_rarity); + if (!r) r = (a.bigbook_tier - b.bigbook_tier); + if (!r) r = a.symbol.localeCompare(b.symbol); + return r; + }); + if (!quippedCrew?.length) { + if (crewman){ + sendAndCache(message, `Couldn't find any quipped crew in your profile that matches '${crewman}'. If you think this is a mistake, please update your profile, and try again.`) + return; + } + else { + sendAndCache(message, "Couldn't find any quipped crew in your profile. If you think this is a mistake, please update your profile, and try again.") + return; + } + } + const embeds = [] as EmbedBuilder[]; + quippedCrew.slice(0, 5).forEach((can: PlayerCrew) => { + const matched = botCrew.find((crew) => { + return crew.symbol === can.symbol + }) as Definitions.BotCrew; + + if (!matched) { + return; + } + + let embed = new EmbedBuilder() + .setTitle(`${matched.name}`) + .setDescription(`Current Quipment`) + .setThumbnail(`${CONFIG.ASSETS_URL}${matched.imageUrlPortrait}`) + .setColor(colorFromRarity(matched.max_rarity)) + .addFields( + // { + // name: 'Rarity', + // value: '⭐'.repeat(can.rarity) + '🌑'.repeat(matched.max_rarity - can.rarity), + // inline: false + // }, + { + name: `Immortalized Stats`, + value: formatCurrentStatLine(message, { ... matched, ...can }), + inline: false + }, + { + name: `Quipped Stats`, + value: formatSkillsStatsWithEmotes(message, can.skills), + inline: false + } + ) + + if (can.kwipment_items?.length) { + if (!!crewman) { + embeds.push(embed); + for (let quip of can.kwipment_items) { + let b = getItemBonuses(quip as EquipmentItem).bonuses as Definitions.Skills; + + embed = new EmbedBuilder() + .setTitle(`${quip.name}`) + .setDescription(quip.flavor) + .setThumbnail(`${CONFIG.ASSETS_URL}${quip.imageUrl}`) + .setColor(colorFromRarity(quip.rarity)) + + embed = embed.addFields( { + name: `Buffs`, + value: formatSkillsStatsWithEmotes(message, b), + inline: false + }, + { + name: "Rarity", + value: ((quip.rarity ? '⭐'.repeat(quip.rarity ?? 0) : '')), + inline: true + }, + { + name: "Duration", + value: `${quip.duration} h`, + inline: true + }, + { + name: "Equippable By", + value: `${rarityLabels[(quip.max_rarity_requirement ?? 1) - 1]} crew`, + inline: true + }); + if (quip.traits_requirement?.length) { + embed = embed.addFields({ + name: 'Required Traits', + value: `${quip.traits_requirement?.map(t => appelate(t)).join(` ${quip.traits_requirement_operator} `)}`, + inline: false + }); + } + embeds.push(embed); + } + } + else { + for (let quip of can.kwipment_items) { + let b = getItemBonuses(quip as EquipmentItem).bonuses as Definitions.Skills; + + embed = embed.addFields({ + name: quip.name, + value: ((quip.rarity ? '⭐'.repeat(quip.rarity ?? 0) : '')) + formatSkillsStatsWithEmotes(message, b) + `\nDuration: ${quip.duration} h` + + ((!!quip.traits_requirement?.length) ? `\nTraits: ${quip.traits_requirement?.map(t => appelate(t)).join(` ${quip.traits_requirement_operator} `)}` : '') + + `\nEquippable by ${rarityLabels[(quip.max_rarity_requirement ?? 1) - 1]} crew` + + }) + } + embeds.push(embed); + } + } + + + }); + + sendAndCache(message, + `Currently quipped crew in **${user.profiles[0].captainName}**'s roster (last updated ${toTimestamp(profile.lastModified ?? user.profiles[0].lastUpdate)})`, + { embeds } + ); + +} + +class Quipment implements Definitions.Command { + name = 'quip'; + command = 'quip [crew]'; + aliases = []; + describe = 'Shows currently quipped crew'; + options = [{ + name: 'crew', + type: ApplicationCommandOptionType.String, + description: 'show quipment stats for the specified crew', + required: false + }] + + builder(yp: yargs.Argv): yargs.Argv { + return yp.option('crew', { + alias: 'c', + desc: 'show quipment stats for the specified crew' + }); + } + + handler(args: yargs.Arguments) { + let message = args.message; + let crew = args.crew as string; + args.promisedResult = asyncHandler(message, crew); + } +} + +export let QuipmentCommand = new Quipment(); diff --git a/src/datacore/equipment.ts b/src/datacore/equipment.ts index 5554796..605a01a 100644 --- a/src/datacore/equipment.ts +++ b/src/datacore/equipment.ts @@ -1,19 +1,29 @@ import { Icon } from "./game-elements" import { PlayerCrew, PlayerEquipmentItem } from "./player" -export interface EquipmentCommon extends PlayerEquipmentItem { + +export interface EquipmentCommon extends PlayerEquipmentItem, Definitions.Item { + archetype_id: number; symbol: string type: number name: string flavor: string + //flavorContext?: JSX.Element; rarity: number short_name?: string imageUrl: string - bonuses?: EquipmentBonuses + bonuses: EquipmentBonuses quantity?: number; needed?: number; - factionOnly?: boolean; + factionOnly: boolean; demandCrew?: string[]; + + duration?: number; + max_rarity_requirement?: number; + traits_requirement_operator?: string; // "and" | "or" | "not" | "xor"; + traits_requirement?: string[]; + kwipment?: boolean; + kwipment_id?: number | string; } export interface EquipmentItem extends EquipmentCommon { @@ -24,13 +34,13 @@ export interface EquipmentItem extends EquipmentCommon { rarity: number short_name?: string imageUrl: string - bonuses?: EquipmentBonuses + bonuses: EquipmentBonuses quantity?: number; needed?: number; - factionOnly?: boolean; + factionOnly: boolean; item_sources: EquipmentItemSource[] - recipe?: EquipmentRecipe + recipe: EquipmentRecipe empty?: boolean; isReward?: boolean; diff --git a/src/datacore/player.ts b/src/datacore/player.ts index 380eb03..1237922 100644 --- a/src/datacore/player.ts +++ b/src/datacore/player.ts @@ -451,8 +451,9 @@ export interface Player { */ equipment: number[][] | number[] - kwipment: number[][] - kwipment_expiration: number[] + kwipment: number[][]; + kwipment_expiration: number[]; + kwipment_items?: Definitions.Item[]; q_bits: number icon: Icon diff --git a/src/utils/config.ts b/src/utils/config.ts index a457ac9..fe69a6d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -14,7 +14,7 @@ export default class CONFIG { medicine_skill: 'Medicine' }; - static readonly STATS_CONFIG: { [index: number]: any } = { + static readonly STATS_CONFIG: { [index: number]: { symbol: string, skill: string, stat: string } } = { 2: { symbol: 'engineering_skill_core', skill: 'engineering_skill', stat: 'Core Skill' }, 3: { symbol: 'engineering_skill_range_min', skill: 'engineering_skill', stat: 'Skill Proficiency Min' }, 4: { symbol: 'engineering_skill_range_max', skill: 'engineering_skill', stat: 'Skill Proficiency Max' }, diff --git a/src/utils/crew.ts b/src/utils/crew.ts index ddf4822..5a547a2 100644 --- a/src/utils/crew.ts +++ b/src/utils/crew.ts @@ -1,12 +1,13 @@ import { ColorResolvable, Message } from 'discord.js'; import { getEmoteOrString } from './discord'; import CONFIG from './config'; +import { PlayerCrew } from '../datacore/player'; function formatSkill(skill: Definitions.Skill, useSpace: boolean, forGauntlet: boolean = false) { if (forGauntlet) { - return `${useSpace ? ' ' : '^'}(${skill.range_min}-${skill.range_max})`; + return `${useSpace ? ' ' : '^'}(${skill.range_min ?? 0}-${skill.range_max ?? 0})`; } else { - return `${skill.core}${useSpace ? ' ' : '^'}(${skill.range_min}-${skill.range_max})`; + return `${skill.core ?? 0}${useSpace ? ' ' : '^'}(${skill.range_min ?? 0}-${skill.range_max ?? 0})`; } } @@ -14,42 +15,42 @@ function formatCrewStatsInternal(skills: Definitions.Skills, useSpace: boolean, // TODO: apply buff config before sort let result: any[] = []; - if (skills.command_skill) { + if (!!skills.command_skill && Object.values(skills.command_skill).some(v => !!v)) { result.push({ val: skills.command_skill.core, text: `CMD ${formatSkill(skills.command_skill, useSpace, forGauntlet)}` }); } - if (skills.science_skill) { + if (!!skills.science_skill && Object.values(skills.science_skill).some(v => !!v)) { result.push({ val: skills.science_skill.core, text: `SCI ${formatSkill(skills.science_skill, useSpace, forGauntlet)}` }); } - if (skills.security_skill) { + if (!!skills.security_skill && Object.values(skills.security_skill).some(v => !!v)) { result.push({ val: skills.security_skill.core, text: `SEC ${formatSkill(skills.security_skill, useSpace, forGauntlet)}` }); } - if (skills.engineering_skill) { + if (!!skills.engineering_skill && Object.values(skills.engineering_skill).some(v => !!v)) { result.push({ val: skills.engineering_skill.core, text: `ENG ${formatSkill(skills.engineering_skill, useSpace, forGauntlet)}` }); } - if (skills.diplomacy_skill) { + if (!!skills.diplomacy_skill && Object.values(skills.diplomacy_skill).some(v => !!v)) { result.push({ val: skills.diplomacy_skill.core, text: `DIP ${formatSkill(skills.diplomacy_skill, useSpace, forGauntlet)}` }); } - if (skills.medicine_skill) { + if (!!skills.medicine_skill && Object.values(skills.medicine_skill).some(v => !!v)) { result.push({ val: skills.medicine_skill.core, text: `MED ${formatSkill(skills.medicine_skill, useSpace, forGauntlet)}` @@ -59,12 +60,17 @@ function formatCrewStatsInternal(skills: Definitions.Skills, useSpace: boolean, return result.sort((a, b) => b.val - a.val).map(a => a.text); } -function formatCrewStats(crew: Definitions.BotCrew, useSpace: boolean, raritySearch: number = 0, forGauntlet: boolean = false) { - let data = crew.skill_data.find(c => c.rarity === raritySearch); - if (data) { - return formatCrewStatsInternal(data.base_skills, useSpace, forGauntlet); +function formatCrewStats(crew: Definitions.BotCrew | PlayerCrew | Definitions.Skills, useSpace: boolean, raritySearch: number = 0, forGauntlet: boolean = false) { + if ("skill_data" in crew) { + let data = crew.skill_data.find(c => c.rarity === raritySearch); + if (data) { + return formatCrewStatsInternal(data.base_skills, useSpace, forGauntlet); + } + return formatCrewStatsInternal(crew.base_skills, useSpace, forGauntlet); + } + else { + return formatCrewStatsInternal(crew, useSpace, forGauntlet); } - return formatCrewStatsInternal(crew.base_skills, useSpace, forGauntlet); } export function formatCrewCoolRanks(crew: Definitions.BotCrew, orEmpty: boolean = false, separator: string = ', ') { @@ -105,9 +111,28 @@ export function formatCrewCoolRanks(crew: Definitions.BotCrew, orEmpty: boolean } } +export function formatSkillsStatsWithEmotes( + message: Message, + skills: Definitions.Skills, + raritySearch: number = 0, + forGauntlet: boolean = false +) { + let formattedStats = formatCrewStats(skills, true, raritySearch, forGauntlet) + .map(stat => stat.replace('^', ' ')) + .join(' ') + .replace('SCI', getEmoteOrString(message, 'sci', 'SCI')) + .replace('SEC', getEmoteOrString(message, 'sec', 'SEC')) + .replace('ENG', getEmoteOrString(message, 'eng', 'ENG')) + .replace('DIP', getEmoteOrString(message, 'dip', 'DIP')) + .replace('CMD', getEmoteOrString(message, 'cmd', 'CMD')) + .replace('MED', getEmoteOrString(message, 'med', 'MED')); + + return formattedStats; +} + export function formatCrewStatsWithEmotes( message: Message, - crew: Definitions.BotCrew, + crew: Definitions.BotCrew | PlayerCrew, raritySearch: number = 0, forGauntlet: boolean = false ) { @@ -259,7 +284,7 @@ export function colorFromRarity(rarity: number): ColorResolvable { } } -export function formatStatLine(message: Message, crew: Definitions.BotCrew, raritySearch: number) { +export function formatStatLine(message: Message, crew: Definitions.BotCrew | PlayerCrew, raritySearch: number) { if (raritySearch >= crew.max_rarity) { raritySearch = 1; } @@ -275,6 +300,15 @@ export function formatStatLine(message: Message, crew: Definitions.BotCrew, rari ); } +export function formatCurrentStatLine(message: Message, crew: Definitions.BotCrew | PlayerCrew) { + return ( + '⭐'.repeat(crew.max_rarity) + + '\n' + + formatCrewStatsWithEmotes(message, crew) + ); +} + + export function formatCollectionName(collection: string): string { return `[${collection}](${CONFIG.DATACORE_URL}collections?select=${encodeURIComponent(collection)})`; diff --git a/src/utils/definitions.d.ts b/src/utils/definitions.d.ts index f64fab2..6dab7ee 100644 --- a/src/utils/definitions.d.ts +++ b/src/utils/definitions.d.ts @@ -215,6 +215,14 @@ declare namespace Definitions { bonuses: { [key: string]: number }; imageUrl: string; factionOnly: boolean; + + + duration?: number; + max_rarity_requirement?: number; + traits_requirement_operator?: string; // "and" | "or" | "not" | "xor"; + traits_requirement?: string[]; + kwipment?: boolean; + kwipment_id?: number | string; } export interface UpcomingEvent { diff --git a/src/utils/items.ts b/src/utils/items.ts index 71f7f19..478efcb 100644 --- a/src/utils/items.ts +++ b/src/utils/items.ts @@ -2,6 +2,8 @@ import { Message } from 'discord.js'; import { DCData } from '../data/DCData'; import { getEmoteOrString } from './discord'; import CONFIG from '../utils/config'; +import { Skill } from '../datacore/crew'; +import { EquipmentItem } from '../datacore/equipment'; export function formatRecipe(message: Message, item: Definitions.Item, rich: boolean = false) { if (!item.recipe || !item.recipe.list || item.recipe.list.length === 0) { @@ -106,3 +108,64 @@ function formatQuestName(quest: any, long: boolean): string { return `${quest.name} (${quest.mission.episode_title})`; } } + + +/** + * Creates a formatted title (appelation) from the given text. + * @param text The text to convert into a title + * @returns + */ +export function appelate(text: string) { + let curr: string = ""; + let cpos = 0; + + const match = new RegExp(/[A-Za-z0-9]/); + + for (let ch of text) { + if (match.test(ch)) { + if (cpos++ === 0) { + curr += ch.toUpperCase(); + } + else { + curr += ch.toLowerCase(); + } + } + else { + cpos = 0; + curr += ch == '_' ? " " : ch; + } + } + + return curr; +} + + +export interface ItemBonusInfo { + bonusText: string[]; + bonuses: { [key: string]: Skill }; +} + +export function getItemBonuses(item: EquipmentItem): ItemBonusInfo { + let bonusText = [] as string[]; + let bonuses = {} as { [key: string]: Skill }; + + if (item.bonuses) { + for (let [key, value] of Object.entries(item.bonuses)) { + let bonus = CONFIG.STATS_CONFIG[Number.parseInt(key)]; + if (bonus) { + bonusText.push(`+${value} ${bonus.symbol}`); + bonuses[bonus.skill] ??= {} as Skill; + let stat = bonus.symbol.replace(`${bonus.skill}_`, ''); + (bonuses[bonus.skill] as any)[stat] = value; + bonuses[bonus.skill].skill = bonus.skill; + } else { + // TODO: what kind of bonus is this? + } + } + } + + return { + bonusText, + bonuses + }; +}