@zerospacegg/vynthra
Version:
Discord bot for ZeroSpace.gg data
438 lines (384 loc) ⢠15 kB
text/typescript
import type { BotSubcommand } from "./types.js";
import type {
SlashCommandSubcommandBuilder,
ChatInputCommandInteraction,
} from "discord.js";
/**
* Helper function to create a custom subcommand for the bot
*
* @param name - The name of the subcommand
* @param description - The description of the subcommand
* @param builder - Function to configure the subcommand options
* @param execute - Function to handle the subcommand execution
* @returns A BotSubcommand object ready to be registered
*
* @example
* ```typescript
* const mySubcommand = createSubcommand(
* "mycommand",
* "My custom command description",
* (subcommand) => subcommand
* .addStringOption(option =>
* option
* .setName("input")
* .setDescription("Some input")
* .setRequired(true)
* ),
* async (interaction) => {
* const input = interaction.options.getString("input", true);
* await interaction.reply(`You said: ${input}`);
* }
* );
* ```
*/
export function createSubcommand(
name: string,
description: string,
builder: (
subcommand: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder,
execute: (interaction: ChatInputCommandInteraction) => Promise<void>,
): BotSubcommand {
return {
name,
description,
builder: (subcommand) =>
builder(subcommand.setName(name).setDescription(description)),
execute,
};
}
/**
* Helper function to create a simple subcommand with just a query parameter
* This is useful for commands that follow the same pattern as the stats command
*
* @param name - The name of the subcommand
* @param description - The description of the subcommand
* @param queryDescription - Description for the query parameter
* @param execute - Function to handle the subcommand execution
* @returns A BotSubcommand object ready to be registered
*
* @example
* ```typescript
* const searchSubcommand = createQuerySubcommand(
* "search",
* "Search for something",
* "What to search for",
* async (interaction) => {
* const query = interaction.options.getString("query", true);
* // Handle search logic here
* await interaction.reply(`Searching for: ${query}`);
* }
* );
* ```
*/
export function createQuerySubcommand(
name: string,
description: string,
queryDescription: string,
execute: (interaction: ChatInputCommandInteraction) => Promise<void>,
): BotSubcommand {
return createSubcommand(
name,
description,
(subcommand) =>
subcommand.addStringOption((option) =>
option
.setName("query")
.setDescription(queryDescription)
.setRequired(true),
),
execute,
);
}
/**
* Validates that a subcommand has all required properties
*
* @param subcommand - The subcommand to validate
* @throws Error if the subcommand is invalid
*/
export function validateSubcommand(subcommand: BotSubcommand): void {
if (!subcommand.name || typeof subcommand.name !== "string") {
throw new Error("Subcommand must have a valid name");
}
if (!subcommand.description || typeof subcommand.description !== "string") {
throw new Error("Subcommand must have a valid description");
}
if (!subcommand.builder || typeof subcommand.builder !== "function") {
throw new Error("Subcommand must have a valid builder function");
}
if (!subcommand.execute || typeof subcommand.execute !== "function") {
throw new Error("Subcommand must have a valid execute function");
}
// Validate command name follows Discord's naming rules
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(subcommand.name)) {
throw new Error(
"Subcommand name must be 1-32 characters long, start with a lowercase letter, and contain only lowercase letters, numbers, hyphens, and underscores",
);
}
// Validate description length (Discord limit is 100 characters)
if (subcommand.description.length > 100) {
throw new Error("Subcommand description must be 100 characters or less");
}
}
/**
* Validates an array of subcommands
*
* @param subcommands - Array of subcommands to validate
* @throws Error if any subcommand is invalid or if there are duplicate names
*/
export function validateSubcommands(subcommands: BotSubcommand[]): void {
const names = new Set<string>();
for (const subcommand of subcommands) {
validateSubcommand(subcommand);
if (names.has(subcommand.name)) {
throw new Error(`Duplicate subcommand name: ${subcommand.name}`);
}
names.add(subcommand.name);
}
}
/**
* ZS.GG faction colors for Discord embeds
*/
const FACTION_COLORS = {
// Main factions
'grell': 0x71e725, // --gg-lime
'protectorate': 0x147ced, // --gg-sapphire
'legion': 0xde4a49, // --gg-rust
// Mercenary factions
'arandi': 0x5a11ae, // --gg-purple
'chakru': 0x2cb1e3, // --gg-aqua
'dread': 0xfb2a43, // --gg-ruby
'koru': 0xc8ef24, // --gg-pickle
'marran': 0xed5f13, // --gg-orange
'sanctuary': 0x06c998, // --gg-teal
'valkaru': 0x90409a, // --gg-plum
'xol': 0xf5146e, // --gg-rose
// Neutral/other
'neutral': 0x697380, // --gg-steel
'jungle-ai': 0xecca0d, // --gg-banana
// ZS.GG brand colors
'zsgg': 0x920075, // --gg-fuchsia
'zsgg-alt': 0xdc8642, // --gg-tangerine
};
/**
* Get faction color for Discord embeds
*/
export function getFactionColor(factionName?: string): number {
if (!factionName) return FACTION_COLORS.neutral;
return FACTION_COLORS[factionName.toLowerCase() as keyof typeof FACTION_COLORS] || FACTION_COLORS.neutral;
}
/**
* Render search result as Discord embed
*/
export function renderSearchResultAsEmbed(searchResult: any, query: string): { embeds: any[] } {
const { type, entity, fullEntity, matches } = searchResult;
if (type === "single") {
return renderEntityAsEmbed(entity, fullEntity);
} else if (type === "multi") {
return renderMultipleResultsAsEmbed(matches, query);
} else if (type === "none") {
return renderNoResultsAsEmbed(query);
}
// Fallback
return { embeds: [{
color: FACTION_COLORS.zsgg,
title: "š Stats Search Results",
description: `Search query: **${query}**`,
timestamp: new Date().toISOString(),
}] };
}
function renderEntityAsEmbed(entity: any, fullEntity: any): { embeds: any[] } {
const entityData = fullEntity || entity;
// Get entity type icon
let typeIcon = "š¦";
if (entityData?.subtype === "hero") typeIcon = "ā";
else if (entityData?.type === "unit") typeIcon = "šŖ";
else if (entityData?.type === "building") typeIcon = "šļø";
else if (entityData?.type === "faction") typeIcon = "āļø";
else if (entityData?.type === "map") typeIcon = "šŗļø";
else if (entityData?.type === "mercenary") typeIcon = "š¤";
const title = `${typeIcon} ${entityData?.name || "Unknown"}`;
const color = getFactionColor(entityData?.faction);
let description = entityData?.typeDesc || `${entityData?.type || "Unknown"} ⢠${entityData?.factionName || entityData?.faction || "Various"}`;
// Add basic stats if available
const statParts: string[] = [];
if (entityData?.hp) statParts.push(`ā¤ļø HP ${entityData.hp}`);
if (entityData?.speed) statParts.push(`ā” Speed ${entityData.speed}`);
if (entityData?.supply) statParts.push(`š Supply ${entityData.supply}`);
if (statParts.length > 0) {
description += `\n\n**Stats:** ${statParts.join(' ⢠')}`;
}
// Add cost info if available
const costParts: string[] = [];
if (entityData?.buildTime) costParts.push(`ā±ļø ${entityData.buildTime}s build`);
if (entityData?.rebuildTime && entityData.rebuildTime !== entityData.buildTime) {
costParts.push(`š ${entityData.rebuildTime}s rebuild`);
}
if (costParts.length > 0) {
description += `\n\n**Cost:** ${costParts.join(' ⢠')}`;
}
// Add key abilities if available
if (entityData?.attacks && Object.keys(entityData.attacks).length > 0) {
const attack = Object.values(entityData.attacks)[0] as any;
if (attack?.damage && attack?.cooldown) {
const dps = Math.round(attack.damage / attack.cooldown * 10) / 10;
description += `\n\n**Attack:** ${attack.damage} dmg (${dps} DPS)`;
if (attack.range) description += ` ⢠šÆ ${attack.range} range`;
}
}
// Add spells if available
if (entityData?.spells && Object.keys(entityData.spells).length > 0) {
const spells = Object.values(entityData.spells).slice(0, 5); // Limit to 5 abilities
description += `\n\n**Abilities:**`;
spells.forEach((spell: any) => {
const spellParts: string[] = [];
// Add cooldown if available
if (spell.cooldown) spellParts.push(`ā±ļø ${spell.cooldown}s`);
// Add targets if available
if (spell.targets && spell.targets.length > 0) {
const targetStr = spell.targets.join(', ').replace(/:/g, ' ');
spellParts.push(`šÆ ${targetStr}`);
}
// Add damage if available
if (spell.damage) spellParts.push(`āļø ${spell.damage} dmg`);
// Add healing if available
if (spell.healing) spellParts.push(`ā¤ļø ${spell.healing} heal`);
// Build the ability line
const statsStr = spellParts.length > 0 ? ` (${spellParts.join(' ⢠')})` : '';
const desc = spell.description ? ` - ${spell.description}` : '';
description += `\n⢠**${spell.name}**${statsStr}${desc}`;
});
}
// Add tags (simplified)
if (entityData?.tags && entityData.tags.length > 0) {
const importantTags = entityData.tags.filter((tag: string) =>
!tag.startsWith('faction:') && !tag.startsWith('tier:') && tag !== 'unit' && tag !== 'building'
).slice(0, 5);
if (importantTags.length > 0) {
description += `\n\n**š·ļø Tags:** ${importantTags.join(' ⢠')}`;
}
}
// Add ZS.GG link if available
if (entityData?.zsggPath) {
description += `\n\nš [View on ZS.GG](${entityData.zsggPath})`;
}
return { embeds: [{
color,
title,
description,
timestamp: new Date().toISOString(),
}] };
}
function renderMultipleResultsAsEmbed(matches: any[], query: string): { embeds: any[] } {
const title = `š Multiple Results (${matches.length})`;
const description = `Found ${matches.length} matches for **${query}**\n\n` +
matches.slice(0, 10).map((match, index) => {
const typeEmoji = match.type === "unit" ? "šŖ" :
match.type === "building" ? "šļø" :
match.type === "map" ? "šŗļø" : "š¦";
return `${index + 1}. ${typeEmoji} **${match.name}** (${match.type})`;
}).join('\n') +
(matches.length > 10 ? `\n\n*...and ${matches.length - 10} more*` : '') +
'\n\nš” *Try a more specific search term to get detailed info*';
return { embeds: [{
color: 0xf39c12, // Orange for multiple results
title,
description,
timestamp: new Date().toISOString(),
}] };
}
function renderNoResultsAsEmbed(query: string): { embeds: any[] } {
return { embeds: [{
color: 0xe74c3c, // Red for no results
title: "ā No Results Found",
description: `No matches found for **${query}**.\n\nš” *Try different search terms or check spelling*`,
timestamp: new Date().toISOString(),
}] };
}
/**
* Render match response as Discord embed
*/
export function renderMatchResponseAsEmbed(result: any, generationTime: number): { embeds: any[] } {
const { gameSet } = result;
const { gameType, gamesCount, games, players } = gameSet;
const mainEmbed = {
color: FACTION_COLORS.zsgg,
title: "šā” I'Olin's All-Random MatchMaker Results! š®āØ",
description: `**${gameType.toUpperCase()}** (${getGameTypeDescription(gameType)}) ⢠Best of ${gamesCount} ⢠${players.join(", ")}`,
timestamp: new Date().toISOString(),
footer: {
text: `Generated in ${generationTime}ms ⢠Portal hop complete! Ready for cosmic domination!`,
},
fields: [] as any[]
};
// Add each game as a field
for (let i = 0; i < games.length; i++) {
const game = games[i];
let fieldValue = `**Host:** ${game.host}\n**Map:** ${getEntityName(game.map)}\n\n`;
if (gameType === "2v2") {
// Special formatting for 2v2 teams
for (let teamIdx = 0; teamIdx < 2; teamIdx++) {
const teamName = teamIdx === 0 ? "Team 1" : "Team 2";
fieldValue += `**${teamName}:**\n`;
for (let playerIdx = 0; playerIdx < 2; playerIdx++) {
const gamePlayerIdx = teamIdx * 2 + playerIdx;
const player = game.players[gamePlayerIdx];
const playerName = players[gamePlayerIdx];
const factionName = getFactionEmoji(player.faction) + " " + getEntityName(player.faction);
const mercName = getMercEmoji(player.merc) + " " + getEntityName(player.merc);
const heroName = "ā " + getEntityName(player.hero);
fieldValue += `${playerName}: ${factionName} + ${mercName} ā ${heroName}\n`;
}
fieldValue += "\n";
}
} else {
// Standard formatting for other game types
for (let j = 0; j < game.players.length; j++) {
const player = game.players[j];
const playerName = players[j];
const factionName = getFactionEmoji(player.faction) + " " + getEntityName(player.faction);
const mercName = getMercEmoji(player.merc) + " " + getEntityName(player.merc);
const heroName = "ā " + getEntityName(player.hero);
fieldValue += `**${playerName}:** ${factionName} + ${mercName} ā ${heroName}\n`;
}
}
mainEmbed.fields.push({
name: `Game ${i + 1}`,
value: fieldValue.trim(),
inline: games.length > 1 ? true : false
});
}
return { embeds: [mainEmbed] };
}
// Helper functions for match embed rendering
function getGameTypeDescription(gameType: string): string {
switch (gameType) {
case "solo": return "Practice vs AI";
case "1v1": return "Two players";
case "2v2": return "Four players, teams";
case "ffa": return "Four players, free for all";
default: return gameType;
}
}
function getEntityName(entityId: string): string {
const parts = entityId.split("/");
const name = parts[parts.length - 1];
return name
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function getFactionEmoji(factionId: string): string {
if (factionId.includes("grell")) return "š§¬";
if (factionId.includes("protectorate")) return "šļø";
if (factionId.includes("legion")) return "š„";
return "āļø";
}
function getMercEmoji(mercId: string): string {
if (mercId.includes("arandi")) return "š";
if (mercId.includes("valkaru")) return "šØ";
if (mercId.includes("marran")) return "šÆ";
if (mercId.includes("chakru")) return "š¦";
if (mercId.includes("dread")) return "š";
return "š¤";
}