Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions api/ApiHelper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,36 @@ export function initAPI(returnSSRResponse: boolean = false): API {
})
}

let getBestMinions = (options: MinionRankingOptions): Promise<MinionRankingResponse> => {
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<void> => {
return postApiPlayerPlayerUuidName(playerUUID)
.then(() => {})
Expand Down Expand Up @@ -2271,6 +2301,7 @@ export function initAPI(returnSSRResponse: boolean = false): API {
playerSearch,
getProfitableCrafts,
getCraftAcquisitionPlan,
getBestMinions,
getLowSupplyItems,
sendFeedback,
triggerPlayerNameCheck,
Expand Down
1 change: 1 addition & 0 deletions api/ApiTypes.d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
78 changes: 78 additions & 0 deletions app/minions/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<keyof MinionRankingOptions, string | string[]>>

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<T extends string>(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<MinionSearchParams> }) {
const options = parseOptions(await searchParams)
const ranking = await initAPI(true).getBestMinions(options)

return (
<>
<Container>
<Search />
<h1>Hypixel SkyBlock Minion Calculator</h1>
<p className="lead">Find the best minion for the time you actually leave it running.</p>
<hr />
<MinionCalculator initialRanking={ranking} initialOptions={options} />
<ToolLandingSeo content={seoContent} />
</Container>
<BottomBanner />
</>
)
}

export const metadata = getHeadMetadata(seoContent.metadataTitle, seoContent.metadataDescription, undefined, undefined, undefined, getCanonicalUrl('/minions'))

export const revalidate = 0
263 changes: 263 additions & 0 deletions components/MinionCalculator/MinionCalculator.tsx
Original file line number Diff line number Diff line change
@@ -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<MinionRankingOptions> }[] = [
{ 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 (
<section aria-label="Minion calculator">
<div className="mb-3">
<h2 className="h4">Start with your collection plan</h2>
<div className="d-flex flex-wrap gap-2">
{presets.map(preset => (
<Button key={preset.label} variant="outline-primary" onClick={() => calculate({ ...options, ...preset.options })} disabled={loading}>
{preset.label} <small className="d-block">{preset.description}</small>
</Button>
))}
</div>
</div>

<Form onSubmit={submit} className="border rounded p-3 mb-4">
<Row className="g-3">
<Col md={4} lg={3}>
<Form.Label htmlFor="offlineHours">Hours between collections</Form.Label>
<Form.Control
id="offlineHours"
type="number"
min={1}
step="any"
value={offlineHoursInput}
onChange={event => setOfflineHoursInput(event.target.value)}
/>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="budget">Setup budget (optional)</Form.Label>
<Form.Control
id="budget"
type="number"
min={0}
placeholder="No limit"
value={budgetInput}
onChange={event => setBudgetInput(event.target.value)}
/>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="objective">Rank by</Form.Label>
<Form.Select
id="objective"
value={options.objective}
onChange={event => setOptions({ ...options, objective: event.target.value as MinionRankingOptions['objective'] })}
>
<option value="coins">Coins per day</option>
<option value="experience">Skill XP per day</option>
</Form.Select>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="speedBoost">Additive speed boost (%)</Form.Label>
<Form.Control
id="speedBoost"
type="number"
min={0}
step="any"
value={speedBoostInput}
onChange={event => setSpeedBoostInput(event.target.value)}
/>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="buy">Buy ingredients with</Form.Label>
<Form.Select
id="buy"
value={options.buy}
onChange={event => setOptions({ ...options, buy: event.target.value as MinionRankingOptions['buy'] })}
>
<option value="instant">Instant buys</option>
<option value="order">Buy orders</option>
</Form.Select>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="sell">Sell products with</Form.Label>
<Form.Select
id="sell"
value={options.sell}
onChange={event => setOptions({ ...options, sell: event.target.value as MinionRankingOptions['sell'] })}
>
<option value="offer">Sell offers</option>
<option value="instant">Instant sells</option>
<option value="npc">NPC sales</option>
</Form.Select>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="hopper">Hopper</Form.Label>
<Form.Select
id="hopper"
value={options.hopper}
onChange={event => setOptions({ ...options, hopper: event.target.value as MinionRankingOptions['hopper'] })}
>
<option value="none">None</option>
<option value="budget">Budget Hopper</option>
<option value="enchanted">Enchanted Hopper</option>
</Form.Select>
</Col>
<Col md={4} lg={3}>
<Form.Label htmlFor="limit">Results</Form.Label>
<Form.Control id="limit" type="number" min={1} max={50} value={limitInput} onChange={event => setLimitInput(event.target.value)} />
</Col>
<Col xs={12} className="d-flex flex-wrap gap-4">
<Form.Check
id="compaction"
type="switch"
label="Allow Super Compactor 3000"
checked={options.compaction}
onChange={event => setOptions({ ...options, compaction: event.target.checked })}
/>
<Form.Check
id="derpy"
type="switch"
label="Derpy is active"
checked={options.derpy}
onChange={event => setOptions({ ...options, derpy: event.target.checked })}
/>
</Col>
<Col xs={12}>
<Button type="submit" disabled={loading}>
{loading ? <Spinner size="sm" aria-label="Calculating" /> : 'Calculate ranking'}
</Button>
</Col>
</Row>
</Form>

{error ? <Alert variant="danger">The ranking could not be loaded. Change an input or try again shortly.</Alert> : null}
{ranking.generatedAt ? <p className="text-body-secondary">Prices read {new Date(ranking.generatedAt).toLocaleString()}.</p> : null}

<Table responsive striped hover className="align-middle">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Minion</th>
<th scope="col">Coins/day</th>
<th scope="col">XP/day</th>
<th scope="col">Setup / payback</th>
<th scope="col">Storage</th>
<th scope="col">Products and limits</th>
</tr>
</thead>
<tbody data-cy="minion-ranking">
{rows.map((minion, index) => (
<tr key={`${minion.name}-${minion.tier}`}>
<td>{index + 1}</td>
<td>
<strong>{minion.name ?? 'Unknown minion'}</strong>
<div>Tier {minion.tier}</div>
<small>{formatNumber.format(minion.secondsBetweenHarvests)} seconds per harvest</small>
</td>
<td>{formatNumber.format(minion.coinsPerDay)}</td>
<td>{formatNumber.format(minion.experiencePerDay)}</td>
<td>
{formatNumber.format(minion.setupCost)} coins
<div>{minion.paybackDays == null ? 'No payback' : `${formatNumber.format(minion.paybackDays)} days`}</div>
</td>
<td>
{formatNumber.format(minion.hoursToFill)} hours
<div>
<Badge bg={minion.storageLimited ? 'warning' : 'success'} text={minion.storageLimited ? 'dark' : undefined}>
{minion.storageLimited ? 'Fills before collection' : 'Runs until collection'}
</Badge>
</div>
<small>{minion.compacted ? 'Compacted' : 'Not compacted'}</small>
</td>
<td>
{minion.itemPages?.map((itemPage, itemIndex) => (
<span key={itemPage}>
{itemIndex ? ', ' : ''}
<Link href={itemPage}>{minion.productTags?.[itemIndex] ?? 'Price'}</Link>
</span>
))}
{minion.missingRequirements?.length ? (
<div className="text-warning-emphasis">Requires: {minion.missingRequirements.join(', ')}</div>
) : null}
{minion.unpricedIngredients?.length ? (
<div className="text-warning-emphasis">Unpriced: {minion.unpricedIngredients.join(', ')}</div>
) : null}
</td>
</tr>
))}
</tbody>
</Table>
{!loading && !error && rows.length === 0 ? <Alert variant="info">No minions match this setup.</Alert> : null}
</section>
)
}
Loading
Loading