|
| 1 | +import delay from 'delay' |
| 2 | +import { |
| 3 | + type ChatInputCommandInteraction, |
| 4 | + SlashCommandBuilder, |
| 5 | +} from 'discord.js' |
| 6 | +import { chunk, keyBy } from 'lodash-es' |
| 7 | +import type { z } from 'zod' |
| 8 | +import { itemId } from '~/(constants)/itemId' |
| 9 | +import { universalisApp } from '~/(services)/universalis/universalisApp' |
| 10 | +import { universalisTypes } from '~/(services)/universalis/universalisTypes' |
| 11 | + |
| 12 | +type AggregatedItemResult = z.infer< |
| 13 | + typeof universalisTypes.aggregatedItems |
| 14 | +>['results'][number] |
| 15 | + |
| 16 | +export const materiaCommand = { |
| 17 | + command: new SlashCommandBuilder() |
| 18 | + .setName('materia') |
| 19 | + .setDescription('魔晶石查詢'), |
| 20 | + callback: async function handleMateriaCommand( |
| 21 | + interaction: ChatInputCommandInteraction, |
| 22 | + ): Promise<void> { |
| 23 | + const MATERIA_CONFIG = [ |
| 24 | + // 🔴 紅色系列(戰鬥特職) |
| 25 | + { emoji: '🔴', name: '武略魔晶石', attr: '爆擊' }, |
| 26 | + { emoji: '🔴', name: '神眼魔晶石', attr: '直擊' }, |
| 27 | + { emoji: '🔴', name: '雄略魔晶石', attr: '信念' }, |
| 28 | + |
| 29 | + // 🔵 藍色系列(生產系特職) |
| 30 | + { emoji: '🔵', name: '巨匠魔晶石', attr: '加工精度' }, |
| 31 | + { emoji: '🔵', name: '名匠魔晶石', attr: '作業精度' }, |
| 32 | + { emoji: '🔵', name: '魔匠魔晶石', attr: 'CP' }, |
| 33 | + |
| 34 | + // 🟢 綠色系列(採集系特職) |
| 35 | + { emoji: '🟢', name: '達識魔晶石', attr: '獲得力' }, |
| 36 | + { emoji: '🟢', name: '博識魔晶石', attr: '鑑別力' }, |
| 37 | + { emoji: '🟢', name: '器識魔晶石', attr: 'GP' }, |
| 38 | + |
| 39 | + // 🟣 紫色系列(戰鬥特職) |
| 40 | + { emoji: '🟣', name: '戰技魔晶石', attr: '技能速度' }, |
| 41 | + { emoji: '🟣', name: '詠唱魔晶石', attr: '詠唱速度' }, |
| 42 | + |
| 43 | + // 🟡 黃色系列(坦克/治療特職) |
| 44 | + { emoji: '🟡', name: '剛柔魔晶石', attr: '堅韌' }, |
| 45 | + { emoji: '🟡', name: '信力魔晶石', attr: '信仰' }, |
| 46 | + ] |
| 47 | + |
| 48 | + const TYPE_NAMES = [ |
| 49 | + '壹型', |
| 50 | + '貳型', |
| 51 | + '參型', |
| 52 | + '肆型', |
| 53 | + '伍型', |
| 54 | + '陸型', |
| 55 | + '柒型', |
| 56 | + '捌型', |
| 57 | + '玖型', |
| 58 | + '拾型', |
| 59 | + '拾壹型', |
| 60 | + '拾貳型', |
| 61 | + ] |
| 62 | + |
| 63 | + const result = await interaction.reply('🔍 查詢所有魔晶石物價ing...') |
| 64 | + |
| 65 | + // 收集所有魔晶石物品(保持排序) |
| 66 | + const materiaItems = MATERIA_CONFIG.flatMap((materia) => |
| 67 | + TYPE_NAMES.map((typeName, typeIndex) => { |
| 68 | + const fullName = `${materia.name}${typeName}` |
| 69 | + const materiaId = itemId.get(fullName) |
| 70 | + return { |
| 71 | + ...materia, |
| 72 | + typeName, |
| 73 | + typeNumber: typeIndex + 1, |
| 74 | + fullName, |
| 75 | + id: materiaId, |
| 76 | + } |
| 77 | + }), |
| 78 | + ).filter((item) => item.id !== undefined) |
| 79 | + |
| 80 | + const allMateriaIds = materiaItems.map((item) => item.id!) |
| 81 | + |
| 82 | + // 分批查詢價格(每批 20 個,延遲 1150ms) |
| 83 | + const BATCH_SIZE = 20 |
| 84 | + const priceBatches = chunk(allMateriaIds, BATCH_SIZE) |
| 85 | + const allPriceResults: AggregatedItemResult[] = [] |
| 86 | + |
| 87 | + for (let batchIndex = 0; batchIndex < priceBatches.length; batchIndex++) { |
| 88 | + await result.edit( |
| 89 | + `🔍 查詢價格中 (${batchIndex + 1}/${priceBatches.length})...`, |
| 90 | + ) |
| 91 | + |
| 92 | + const { data: priceData } = await universalisApp.findManyItemsPrice( |
| 93 | + priceBatches[batchIndex]!, |
| 94 | + ) |
| 95 | + if (priceData?.results) { |
| 96 | + allPriceResults.push(...priceData.results) |
| 97 | + } |
| 98 | + |
| 99 | + if (batchIndex < priceBatches.length - 1) { |
| 100 | + await delay(1150) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + const priceMap = keyBy(allPriceResults, 'itemId') |
| 105 | + |
| 106 | + // 分批查詢銷量(每批 20 個,延遲 1150ms) |
| 107 | + await result.edit('🔍 查詢銷量資料中...') |
| 108 | + |
| 109 | + const saleVelocityMap: Record<number, number> = {} |
| 110 | + |
| 111 | + for (let batchIndex = 0; batchIndex < priceBatches.length; batchIndex++) { |
| 112 | + const { data: saleData } = await universalisApp.findItemSaleHistory( |
| 113 | + priceBatches[batchIndex]!, |
| 114 | + ) |
| 115 | + |
| 116 | + if (saleData) { |
| 117 | + saleVelocityMap[saleData.itemID] = saleData.nqSaleVelocity || 0 |
| 118 | + } |
| 119 | + |
| 120 | + if (batchIndex < priceBatches.length - 1) { |
| 121 | + await delay(1150) |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + // 格式化輸出 - 按顏色分組 |
| 126 | + await result.edit('🔍 正在總結...') |
| 127 | + |
| 128 | + const colorGroups = [ |
| 129 | + { emoji: '🔴', name: '紅色系列(戰鬥特職)' }, |
| 130 | + { emoji: '🔵', name: '藍色系列(生產系特職)' }, |
| 131 | + { emoji: '🟢', name: '綠色系列(採集系特職)' }, |
| 132 | + { emoji: '🟣', name: '紫色系列(戰鬥特職)' }, |
| 133 | + { emoji: '🟡', name: '黃色系列(坦克/治療特職)' }, |
| 134 | + ] |
| 135 | + |
| 136 | + for (let groupIndex = 0; groupIndex < colorGroups.length; groupIndex++) { |
| 137 | + const group = colorGroups[groupIndex]! |
| 138 | + const output = [`## ${group.emoji} ${group.name}\n`] |
| 139 | + |
| 140 | + const groupItems = materiaItems.filter( |
| 141 | + (item) => item.emoji === group.emoji, |
| 142 | + ) |
| 143 | + |
| 144 | + for (const item of groupItems) { |
| 145 | + if (!item.id) continue |
| 146 | + |
| 147 | + const price = priceMap[item.id]?.nq.averageSalePrice.region?.price || 0 |
| 148 | + const velocity = saleVelocityMap[item.id] || 0 |
| 149 | + |
| 150 | + /** |
| 151 | + * FIXME: 銷量總是返回 0 的問題。 |
| 152 | + */ |
| 153 | + output.push( |
| 154 | + `${item.emoji} ${item.fullName} (${item.typeNumber}型${item.attr}) / 平均 ${Math.round(price).toLocaleString('en-US')}g / 銷量 ${Math.round(velocity)} 件`, |
| 155 | + ) |
| 156 | + } |
| 157 | + |
| 158 | + if (groupIndex === 0) { |
| 159 | + await result.edit(output.join('\n')) |
| 160 | + } else { |
| 161 | + await interaction.followUp(output.join('\n')) |
| 162 | + } |
| 163 | + } |
| 164 | + }, |
| 165 | +} |
0 commit comments