diff --git a/api/ApiHelper.tsx b/api/ApiHelper.tsx index e83e1cf3..27fe7a94 100644 --- a/api/ApiHelper.tsx +++ b/api/ApiHelper.tsx @@ -1427,6 +1427,36 @@ export function initAPI(returnSSRResponse: boolean = false): API { }) } + let getBestMinions = (options: MinionRankingOptions): Promise => { + let query = new URLSearchParams({ + offlineHours: options.offlineHours.toString(), + sell: options.sell, + buy: options.buy, + objective: options.objective, + speedBoost: options.speedBoost.toString(), + hopper: options.hopper, + compaction: options.compaction.toString(), + derpy: options.derpy.toString(), + limit: options.limit.toString() + }) + if (options.budget !== undefined) { + query.set('budget', options.budget.toString()) + } + + return new Promise((resolve, reject) => { + httpApi.sendApiRequest({ + type: RequestType.GET_BEST_MINIONS, + customRequestURL: `${getApiEndpoint()}/${RequestType.GET_BEST_MINIONS}?${query.toString()}`, + data: '', + resolve, + reject: function (error) { + apiErrorHandler(RequestType.GET_BEST_MINIONS, error, options) + reject(error) + } + }) + }) + } + let triggerPlayerNameCheck = (playerUUID: string): Promise => { return postApiPlayerPlayerUuidName(playerUUID) .then(() => {}) @@ -2271,6 +2301,7 @@ export function initAPI(returnSSRResponse: boolean = false): API { playerSearch, getProfitableCrafts, getCraftAcquisitionPlan, + getBestMinions, getLowSupplyItems, sendFeedback, triggerPlayerNameCheck, diff --git a/api/ApiTypes.d.tsx b/api/ApiTypes.d.tsx index 21100bcf..1015c8a6 100644 --- a/api/ApiTypes.d.tsx +++ b/api/ApiTypes.d.tsx @@ -51,6 +51,7 @@ export enum RequestType { PLAYER_SEARCH = 'search/player', GET_PROFITABLE_CRAFTS = 'craft/profit', GET_CRAFT_ACQUISITION = 'craft/acquisition', + GET_BEST_MINIONS = 'minions/best', GET_LOW_SUPPLY_ITEMS = 'auctions/supply/low', SEND_FEEDBACK = 'sendFeedback', TRIGGER_PLAYER_NAME_CHECK = 'triggerNameCheck', diff --git a/app/minions/page.tsx b/app/minions/page.tsx new file mode 100644 index 00000000..d5793181 --- /dev/null +++ b/app/minions/page.tsx @@ -0,0 +1,78 @@ +import { Container } from 'react-bootstrap' +import { initAPI } from '../../api/ApiHelper' +import { BottomBanner } from '../../components/BottomBanner/BottomBanner' +import { MinionCalculator } from '../../components/MinionCalculator/MinionCalculator' +import Search from '../../components/Search/Search' +import { ToolLandingSeo } from '../../components/Seo/ToolLandingSeo' +import { toolLandingSeoContent } from '../../components/Seo/toolLandingSeoContent' +import { getCanonicalUrl, getHeadMetadata } from '../../utils/SSRUtils' + +const seoContent = toolLandingSeoContent.minions + +const defaults: MinionRankingOptions = { + offlineHours: 24, + sell: 'offer', + buy: 'instant', + objective: 'coins', + speedBoost: 0, + hopper: 'none', + compaction: true, + derpy: false, + limit: 10 +} + +type MinionSearchParams = Partial> + +function first(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value +} + +function numberParam(value: string | undefined, fallback: number, minimum = 0) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= minimum ? parsed : fallback +} + +function enumParam(value: string | undefined, values: readonly T[], fallback: T): T { + return values.includes(value as T) ? (value as T) : fallback +} + +function parseOptions(params: MinionSearchParams): MinionRankingOptions { + const budgetValue = first(params.budget) + const budget = budgetValue ? numberParam(budgetValue, 0) : undefined + + return { + offlineHours: numberParam(first(params.offlineHours), defaults.offlineHours, 1), + budget, + sell: enumParam(first(params.sell), ['offer', 'instant', 'npc'] as const, defaults.sell), + buy: enumParam(first(params.buy), ['instant', 'order'] as const, defaults.buy), + objective: enumParam(first(params.objective), ['coins', 'experience'] as const, defaults.objective), + speedBoost: numberParam(first(params.speedBoost), defaults.speedBoost), + hopper: enumParam(first(params.hopper), ['none', 'budget', 'enchanted'] as const, defaults.hopper), + compaction: first(params.compaction) !== 'false', + derpy: first(params.derpy) === 'true', + limit: Math.floor(Math.min(50, numberParam(first(params.limit), defaults.limit, 1))) + } +} + +export default async function Page({ searchParams }: { searchParams: Promise }) { + const options = parseOptions(await searchParams) + const ranking = await initAPI(true).getBestMinions(options) + + return ( + <> + + +

Hypixel SkyBlock Minion Calculator

+

Find the best minion for the time you actually leave it running.

+
+ + +
+ + + ) +} + +export const metadata = getHeadMetadata(seoContent.metadataTitle, seoContent.metadataDescription, undefined, undefined, undefined, getCanonicalUrl('/minions')) + +export const revalidate = 0 diff --git a/components/MinionCalculator/MinionCalculator.tsx b/components/MinionCalculator/MinionCalculator.tsx new file mode 100644 index 00000000..daf5c1e9 --- /dev/null +++ b/components/MinionCalculator/MinionCalculator.tsx @@ -0,0 +1,263 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { FormEvent, useState } from 'react' +import { Alert, Badge, Button, Col, Form, Row, Spinner, Table } from 'react-bootstrap' +import api from '../../api/ApiHelper' + +interface Props { + initialRanking: MinionRankingResponse + initialOptions: MinionRankingOptions +} + +const presets: { label: string; description: string; options: Partial }[] = [ + { label: 'Collect daily', description: '24 hours', options: { offlineHours: 24, hopper: 'none', derpy: false } }, + { label: 'Collect every Derpy', description: '124 days', options: { offlineHours: 2976, hopper: 'none', derpy: true } }, + { label: 'Leave for years', description: '5 years', options: { offlineHours: 43800, hopper: 'enchanted', derpy: false } } +] + +const formatNumber = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }) + +function toSearchParams(options: MinionRankingOptions) { + const params = new URLSearchParams() + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined && value !== '') params.set(key, String(value)) + }) + return params +} + +export function MinionCalculator({ initialRanking, initialOptions }: Props) { + const router = useRouter() + const [options, setOptions] = useState(initialOptions) + const [offlineHoursInput, setOfflineHoursInput] = useState(String(initialOptions.offlineHours)) + const [budgetInput, setBudgetInput] = useState(initialOptions.budget === undefined ? '' : String(initialOptions.budget)) + const [speedBoostInput, setSpeedBoostInput] = useState(String(initialOptions.speedBoost * 100)) + const [limitInput, setLimitInput] = useState(String(initialOptions.limit)) + const [ranking, setRanking] = useState(initialRanking) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + + async function calculate(nextOptions: MinionRankingOptions) { + nextOptions = { + ...nextOptions, + offlineHours: Math.max(1, nextOptions.offlineHours || 1), + speedBoost: Math.max(0, nextOptions.speedBoost || 0), + limit: Math.min(50, Math.max(1, nextOptions.limit || 1)) + } + setOptions(nextOptions) + setOfflineHoursInput(String(nextOptions.offlineHours)) + setBudgetInput(nextOptions.budget === undefined ? '' : String(nextOptions.budget)) + setSpeedBoostInput(String(nextOptions.speedBoost * 100)) + setLimitInput(String(nextOptions.limit)) + setLoading(true) + setError(false) + router.replace(`/minions?${toSearchParams(nextOptions).toString()}`, { scroll: false }) + + try { + const nextRanking = await api.getBestMinions(nextOptions) + setRanking(nextRanking) + } catch { + setRanking({ minions: [], generatedAt: '' }) + setError(true) + } finally { + setLoading(false) + } + } + + function submit(event: FormEvent) { + event.preventDefault() + calculate({ + ...options, + offlineHours: Number(offlineHoursInput), + budget: budgetInput === '' ? undefined : Number(budgetInput), + speedBoost: Number(speedBoostInput) / 100, + limit: Number(limitInput) + }) + } + + const rows = ranking.minions ?? [] + + return ( +
+
+

Start with your collection plan

+
+ {presets.map(preset => ( + + ))} +
+
+ +
+ + + Hours between collections + setOfflineHoursInput(event.target.value)} + /> + + + Setup budget (optional) + setBudgetInput(event.target.value)} + /> + + + Rank by + setOptions({ ...options, objective: event.target.value as MinionRankingOptions['objective'] })} + > + + + + + + Additive speed boost (%) + setSpeedBoostInput(event.target.value)} + /> + + + Buy ingredients with + setOptions({ ...options, buy: event.target.value as MinionRankingOptions['buy'] })} + > + + + + + + Sell products with + setOptions({ ...options, sell: event.target.value as MinionRankingOptions['sell'] })} + > + + + + + + + Hopper + setOptions({ ...options, hopper: event.target.value as MinionRankingOptions['hopper'] })} + > + + + + + + + Results + setLimitInput(event.target.value)} /> + + + setOptions({ ...options, compaction: event.target.checked })} + /> + setOptions({ ...options, derpy: event.target.checked })} + /> + + + + + +
+ + {error ? The ranking could not be loaded. Change an input or try again shortly. : null} + {ranking.generatedAt ?

Prices read {new Date(ranking.generatedAt).toLocaleString()}.

: null} + + + + + + + + + + + + + + + {rows.map((minion, index) => ( + + + + + + + + + + ))} + +
RankMinionCoins/dayXP/daySetup / paybackStorageProducts and limits
{index + 1} + {minion.name ?? 'Unknown minion'} +
Tier {minion.tier}
+ {formatNumber.format(minion.secondsBetweenHarvests)} seconds per harvest +
{formatNumber.format(minion.coinsPerDay)}{formatNumber.format(minion.experiencePerDay)} + {formatNumber.format(minion.setupCost)} coins +
{minion.paybackDays == null ? 'No payback' : `${formatNumber.format(minion.paybackDays)} days`}
+
+ {formatNumber.format(minion.hoursToFill)} hours +
+ + {minion.storageLimited ? 'Fills before collection' : 'Runs until collection'} + +
+ {minion.compacted ? 'Compacted' : 'Not compacted'} +
+ {minion.itemPages?.map((itemPage, itemIndex) => ( + + {itemIndex ? ', ' : ''} + {minion.productTags?.[itemIndex] ?? 'Price'} + + ))} + {minion.missingRequirements?.length ? ( +
Requires: {minion.missingRequirements.join(', ')}
+ ) : null} + {minion.unpricedIngredients?.length ? ( +
Unpriced: {minion.unpricedIngredients.join(', ')}
+ ) : null} +
+ {!loading && !error && rows.length === 0 ? No minions match this setup. : null} +
+ ) +} diff --git a/components/Seo/toolLandingSeoContent.ts b/components/Seo/toolLandingSeoContent.ts index 149ea9cd..bbbe562c 100644 --- a/components/Seo/toolLandingSeoContent.ts +++ b/components/Seo/toolLandingSeoContent.ts @@ -25,6 +25,58 @@ export interface ToolLandingSeoContent { } export const toolLandingSeoContent = { + minions: { + metadataTitle: 'Hypixel SkyBlock Minion Calculator | Best Minions for Coins and XP', + metadataDescription: + 'Compare Hypixel SkyBlock minions by coins per day, XP, setup cost, storage, collection interval, budget, fuel speed, hopper, compaction, and Derpy.', + intro: [ + 'The best Hypixel SkyBlock minion depends on when you return. Storage can stop an otherwise excellent setup long before your next collection, so this calculator ranks each minion for your actual offline interval instead of publishing one static list.', + 'Compare daily collections, a return during Derpy, or years away. Then adjust budget, market order type, speed, hopper, compaction, and whether coins or skill experience matter most.' + ], + sections: [ + { + title: 'Why the collection interval changes the answer', + paragraphs: [ + 'A fast minion can lead over one day and fall behind over a long absence when its storage fills. The Storage column tells you when production stops, while the storage-limited label explains why a row ranks lower for the selected interval.' + ] + }, + { + title: 'How to compare minion setups', + bullets: [ + 'Use setup cost and payback time together; daily revenue alone can hide an upgrade that takes too long to recover.', + 'Choose the same buy and sell methods you actually use so ingredient costs and output values match your plan.', + 'Check missing requirements and unpriced ingredients before treating the displayed setup cost as complete.' + ] + } + ], + faqs: [ + { + question: 'Which minion is best to collect once a day?', + answer: 'Choose the Daily collection preset. It ranks production over 24 hours and marks any minion whose storage fills before you return.' + }, + { + question: 'Why does the best minion change for Derpy or a long absence?', + answer: 'Derpy changes output and skill experience, while a longer absence gives limited storage more time to fill. Hoppers and compaction can therefore change the ranking materially.' + } + ], + relatedLinks: [ + { + href: '/bazaar', + label: 'Bazaar Prices', + description: 'Inspect the live market behind minion output and material values.' + }, + { + href: '/crafts', + label: 'Craft Flips', + description: 'Compare minion income with profitable item crafting.' + }, + { + href: '/item/COBBLESTONE', + label: 'Cobblestone Price History', + description: 'See how a common minion product is priced before committing to a setup.' + } + ] + }, bazaar: { metadataTitle: 'Hypixel SkyBlock Bazaar Flips | Live Spread, Margin and Volume Scanner', metadataDescription: diff --git a/cypress/e2e/minions.cy.ts b/cypress/e2e/minions.cy.ts new file mode 100644 index 00000000..68611115 --- /dev/null +++ b/cypress/e2e/minions.cy.ts @@ -0,0 +1,49 @@ +const ranking = (name: string, coinsPerDay: number, storageLimited: boolean) => ({ + minions: [ + { + name, + tier: 11, + coinsPerDay, + experiencePerDay: 2400, + setupCost: 1200000, + paybackDays: 4, + secondsBetweenHarvests: 30, + hoursToFill: storageLimited ? 48 : 1000, + storageLimited, + compacted: true, + missingRequirements: [], + unpricedIngredients: [], + productTags: ['COBBLESTONE'], + itemPages: ['https://sky.coflnet.com/item/COBBLESTONE'] + } + ], + generatedAt: '2026-08-24T12:00:00Z' +}) + +describe('Minion calculator', () => { + it('normalizes a fractional result limit from the URL', () => { + cy.visit('/minions?limit=1.5') + cy.get('#limit').should('have.value', '1') + }) + + it('changes the ranking when the collection interval changes', () => { + cy.intercept('GET', '**/api/minions/best*', request => { + const url = new URL(request.url) + const hours = Number(url.searchParams.get('offlineHours')) + request.reply(hours === 100 ? ranking('Long Interval Minion', 250000, true) : ranking('Daily Minion', 900000, false)) + }).as('bestMinions') + + cy.visit('/minions') + cy.get('#offlineHours').clear() + cy.get('#offlineHours').type('24') + cy.contains('button', 'Calculate ranking').click() + cy.wait('@bestMinions').its('request.query.offlineHours').should('equal', '24') + cy.get('[data-cy="minion-ranking"] tr').first().should('contain.text', 'Daily Minion') + + cy.get('#offlineHours').clear() + cy.get('#offlineHours').type('100') + cy.contains('button', 'Calculate ranking').click() + cy.wait('@bestMinions').its('request.query.offlineHours').should('equal', '100') + cy.get('[data-cy="minion-ranking"] tr').first().should('contain.text', 'Long Interval Minion').and('contain.text', 'Fills before collection') + }) +}) diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..cd0f97f8 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/global.d.ts b/global.d.ts index fdd89bed..ec4eb41b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -197,6 +197,41 @@ interface FlipperFilter { onlyUnsold?: boolean } +interface MinionRankingOptions { + offlineHours: number + budget?: number + sell: 'offer' | 'instant' | 'npc' + buy: 'instant' | 'order' + objective: 'coins' | 'experience' + speedBoost: number + hopper: 'none' | 'budget' | 'enchanted' + compaction: boolean + derpy: boolean + limit: number +} + +interface MinionRanking { + name?: string + tier: number + coinsPerDay: number + experiencePerDay: number + setupCost: number + paybackDays?: number | null + secondsBetweenHarvests: number + hoursToFill: number + storageLimited: boolean + compacted: boolean + missingRequirements?: string[] | null + unpricedIngredients?: string[] | null + productTags?: string[] | null + itemPages?: string[] | null +} + +interface MinionRankingResponse { + minions?: MinionRanking[] | null + generatedAt: string +} + interface API { search(searchText: string): Promise trackSearch(fullSearchId: string, fullSearchType: string): void @@ -264,6 +299,7 @@ interface API { sendFeedback(feedbackKey: string, feedback: any): Promise getProfitableCrafts(): Promise getCraftAcquisitionPlan(itemTag: string, quantity?: number, forceCraft?: boolean): Promise + getBestMinions(options: MinionRankingOptions): Promise getLowSupplyItems(): Promise sendFeedback(feedbackKey: string, feedback: any): Promise triggerPlayerNameCheck(playerUUID: string): Promise diff --git a/utils/sitemap-config.ts b/utils/sitemap-config.ts index 3f8e9a2d..76238857 100644 --- a/utils/sitemap-config.ts +++ b/utils/sitemap-config.ts @@ -73,6 +73,14 @@ export const SITEMAP_CONFIG = { description: 'Find profitable crafting recipes with cost analysis and profit calculations.', keywords: ['crafting calculator', 'recipe profits', 'crafting guide', 'item crafting'] }, + { + url: '/minions', + priority: 0.8, + changefreq: 'daily' as const, + title: 'Hypixel SkyBlock Minion Profit and XP Calculator', + description: 'Rank minions by collection interval, storage, budget, coins per day, experience, and setup cost.', + keywords: ['minion calculator', 'best skyblock minion', 'minion profit', 'minion experience'] + }, { url: '/lowSupply', priority: 0.8,