diff --git a/app/crafts/page.tsx b/app/crafts/page.tsx index 184feec20..41e8816e1 100644 --- a/app/crafts/page.tsx +++ b/app/crafts/page.tsx @@ -9,9 +9,10 @@ import { toolLandingSeoContent } from '../../components/Seo/toolLandingSeoConten const seoContent = toolLandingSeoContent.crafts -export default async function Page() { +export default async function Page({ searchParams }: { searchParams: Promise<{ craft?: string | string[] }> }) { let api = initAPI(true) - let [crafts, bazaarTags] = await Promise.all([api.getProfitableCrafts(), api.getBazaarTags()]) + let [crafts, bazaarTags, params] = await Promise.all([api.getProfitableCrafts(), api.getBazaarTags(), searchParams]) + let openCraftTag = Array.isArray(params.craft) ? params.craft[0] : params.craft return ( <> @@ -19,7 +20,7 @@ export default async function Page() {

Profitable Hypixel Skyblock Craft Flips


- + diff --git a/components/CraftsList/CostBreakdown/CostBreakdown.module.css b/components/CraftsList/CostBreakdown/CostBreakdown.module.css new file mode 100644 index 000000000..9a27a4c4b --- /dev/null +++ b/components/CraftsList/CostBreakdown/CostBreakdown.module.css @@ -0,0 +1,55 @@ +.summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + margin-bottom: 16px; +} + +.summary > div { + display: flex; + flex-direction: column; + padding: 8px 10px; + border-radius: 8px; + background: rgba(25, 135, 84, 0.16); +} + +.summary span, +.help, +.depthControl small { + color: #adb5bd; +} + +.summary span { + font-size: 0.8rem; +} + +.summary strong { + color: #75d9a6; +} + +.depthControl { + padding: 12px; + margin-bottom: 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); +} + +.noWaitControl { + padding: 10px 12px; + margin-bottom: 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); +} + +.noWaitControl small { + color: #adb5bd; +} + +.depthControl label { + display: block; + font-weight: 600; +} + +.loading { + color: #75d9a6; +} diff --git a/components/CraftsList/CostBreakdown/CostBreakdown.tsx b/components/CraftsList/CostBreakdown/CostBreakdown.tsx new file mode 100644 index 000000000..7d88d85f2 --- /dev/null +++ b/components/CraftsList/CostBreakdown/CostBreakdown.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useState } from 'react' +import { Alert, Form } from 'react-bootstrap' +import NumberElement from '../../Number/Number' +import { getCombinedShoppingList, getMaxCraftDepth, getShoppingListCost, limitCraftDepth } from '../../../utils/CraftingUtils' +import { IngredientList } from '../IngredientList/IngredientList' +import styles from './CostBreakdown.module.css' + +interface Props { + ingredients: CraftingIngredient[] + sellPrice: number + onItemClick: (ingredient: CraftingIngredient) => void + instructions?: CraftingInstructions + loading?: boolean + noWait?: boolean + onNoWaitChange?: (noWait: boolean) => void +} + +export function CostBreakdown({ ingredients, sellPrice, onItemClick, instructions, loading, noWait: controlledNoWait, onNoWaitChange }: Props) { + const [selectedDepth, setSelectedDepth] = useState() + const [collapsedPaths, setCollapsedPaths] = useState>(new Set()) + const [localNoWait, setLocalNoWait] = useState(false) + const noWait = controlledNoWait ?? localNoWait + const maxDepth = getMaxCraftDepth(ingredients) + const depth = Math.min(selectedDepth ?? maxDepth, maxDepth) + const displayedIngredients = limitCraftDepth(ingredients, depth) + const acquisitionMode = noWait ? 'insta' : 'order' + const shoppingList = getCombinedShoppingList(displayedIngredients, collapsedPaths, acquisitionMode) + const inputCost = getShoppingListCost(shoppingList) + const profit = sellPrice - inputCost + + function toggleIngredient(path: string) { + setCollapsedPaths(current => { + const next = new Set(current) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + } + + return ( +
+
+
+ Selected input cost + + Coins + +
+
+ Potential profit + + Coins + +
+
+
+ { + if (controlledNoWait === undefined) { + setLocalNoWait(event.target.checked) + } + onNoWaitChange?.(event.target.checked) + }} + /> + Uses immediately available NPC stock first, then sell offers for the remainder. Buy orders are skipped. +
+ {maxDepth > 1 ? ( + <> +
+ Maximum craft depth: {depth} + setSelectedDepth(Number(event.target.value))} /> + {depth === 0 ? 'Buy direct inputs.' : `Expand up to ${depth} craft levels before buying.`} +
+
+ + ) : null} +

Combined shopping list

+

Duplicate materials are summed so each item can be purchased in one order.

+ {loading ?

Loading cheaper subcraft options…

: null} + {shoppingList.length ? ( + + ) : ( + No ingredient breakdown is available. + )} + {displayedIngredients.length ? ( + <> +
+

Recipe breakdown

+

Collapse a craft to buy it directly, or expand it to use the cheaper subcraft ingredients.

+ + + ) : null} +
+ ) +} diff --git a/components/CraftsList/CraftDetails/CraftDetails.tsx b/components/CraftsList/CraftDetails/CraftDetails.tsx index fcb86fbc0..0bf039dd2 100644 --- a/components/CraftsList/CraftDetails/CraftDetails.tsx +++ b/components/CraftsList/CraftDetails/CraftDetails.tsx @@ -2,7 +2,7 @@ import { Badge } from 'react-bootstrap' import Number from '../../Number/Number' import { CraftingRecipe } from '../CraftingRecipe/CraftingRecipe' -import { IngredientList } from '../IngredientList/IngredientList' +import { CostBreakdown } from '../CostBreakdown/CostBreakdown' import { useEffect, useState } from 'react' import api from '../../../api/ApiHelper' import { toVariantItemTag } from '../../../utils/Formatter' @@ -20,7 +20,7 @@ export function CraftDetails(props: Props) { }) }, []) - function onItemClick(tag: string) { + function openItem(tag: string) { let detailsPath = instructions?.detailsPath?.[tag] if (detailsPath) { window.open(window.location.origin + detailsPath, '_blank') @@ -28,12 +28,20 @@ export function CraftDetails(props: Props) { window.open(window.location.origin + '/item/' + toVariantItemTag(tag) + '?itemFilter=eyJCaW4iOiJ0cnVlIn0%3D', '_blank') } } + + function openIngredient(ingredient: CraftingIngredient) { + if (ingredient.type === 'craft' || ingredient.ingredients?.length) { + window.open(`${window.location.origin}/crafts?craft=${encodeURIComponent(ingredient.item.tag)}`, '_blank') + return + } + openItem(ingredient.item.tag) + } return (

Recipe

- +
@@ -42,14 +50,7 @@ export function CraftDetails(props: Props) {

-

Ingredient Costs

- { - onItemClick(ingredient.item.tag) - }} - /> +
) } diff --git a/components/CraftsList/CraftsList.tsx b/components/CraftsList/CraftsList.tsx index dfc396950..321ac6219 100644 --- a/components/CraftsList/CraftsList.tsx +++ b/components/CraftsList/CraftsList.tsx @@ -12,6 +12,7 @@ import { GenericFlipList, SortOption } from '../GenericFlipList' interface Props { crafts?: any[] bazaarTags?: string[] + openCraftTag?: string } const SORT_OPTIONS: SortOption[] = [ @@ -61,6 +62,13 @@ const SORT_OPTIONS: SortOption[] = [ export function CraftsList(props: Props) { const crafts = useMemo(() => (props.crafts ? parseProfitableCrafts(props.crafts) : []), [props.crafts]) + const deepLinkedRenderCount = useMemo(() => { + if (!props.openCraftTag) { + return undefined + } + const sortedCrafts = [...crafts].sort((a, b) => b.sellPrice - b.craftCost - (a.sellPrice - a.craftCost)) + return Math.max(42, sortedCrafts.findIndex(craft => craft.item.tag === props.openCraftTag) + 1) + }, [crafts, props.openCraftTag]) function renderFlipContent(craft: ProfitableCraft) { return ( @@ -170,6 +178,7 @@ export function CraftsList(props: Props) { content={<>{content}} tooltipTitle={getCraftHeader(craft)} tooltipContent={} + initiallyOpen={craft.item.tag === props.openCraftTag} /> ) } @@ -188,6 +197,7 @@ export function CraftsList(props: Props) { showColumns={true} sortFunctionArgs={[props.bazaarTags]} customItemWrapper={customItemWrapper} + initialRenderCount={deepLinkedRenderCount} /> ) } diff --git a/components/CraftsList/IngredientList/IngredientList.module.css b/components/CraftsList/IngredientList/IngredientList.module.css index b252af4ad..cb9cc36b1 100644 --- a/components/CraftsList/IngredientList/IngredientList.module.css +++ b/components/CraftsList/IngredientList/IngredientList.module.css @@ -18,4 +18,16 @@ padding: 2px 6px; margin-left: 5px; line-height: 1; -} \ No newline at end of file +} + +.costButton { + margin-left: 5px; + padding: 2px 6px; + line-height: 1.3; +} + +.collapseButton { + margin-left: 5px; + padding: 2px 6px; + line-height: 1.3; +} diff --git a/components/CraftsList/IngredientList/IngredientList.tsx b/components/CraftsList/IngredientList/IngredientList.tsx index 9dd98c155..e521b4d17 100644 --- a/components/CraftsList/IngredientList/IngredientList.tsx +++ b/components/CraftsList/IngredientList/IngredientList.tsx @@ -1,11 +1,12 @@ import Image from 'next/image' -import { useState } from 'react' +import { Fragment, useEffect, useState } from 'react' import Number from '../../Number/Number' -import { Badge, ButtonGroup, OverlayTrigger, Popover, ToggleButton, Tooltip } from 'react-bootstrap' +import { Badge, Button, ButtonGroup, OverlayTrigger, Popover, ToggleButton, Tooltip } from 'react-bootstrap' import styles from './IngredientList.module.css' import api from '../../../api/ApiHelper' import { CopyButton } from '../../CopyButton/CopyButton' -import { AcquisitionMode, getAcquisitionPlan, getCraftSavings, numberWithThousandsSeparators } from '../../../utils/Formatter' +import { AcquisitionMode, getAcquisitionPlan, numberWithThousandsSeparators } from '../../../utils/Formatter' +import { getDirectBuyCost, getIngredientPath } from '../../../utils/CraftingUtils' interface Props { ingredients: CraftingIngredient[] @@ -14,6 +15,10 @@ interface Props { // Cumulative product of ancestor counts down to this level (default 1 for the root call). // Multiplying it by an ingredient's own count gives the total actually needed to craft the item. multiplier?: number + collapsedPaths?: Set + onToggleIngredient?: (path: string) => void + pathPrefix?: string + acquisitionMode?: AcquisitionMode } /** @@ -41,9 +46,16 @@ function formatUnitPrice(price: number): string { * A row in the acquisition breakdown, e.g. "Buy order 27,119 × 12.6 341,699". Only rendered when the * bucket actually contributes units. */ -function BreakdownRow({ label, qty, unitPrice, cost }: { label: string; qty: number; unitPrice: number; cost: number }) { +function BreakdownRow({ label, qty, unitPrice, cost, emptyLabel }: { label: string; qty: number; unitPrice: number; cost: number; emptyLabel?: string }) { if (qty <= 0) { - return null + return emptyLabel ? ( + + {label} + + {emptyLabel} + + + ) : null } return ( @@ -57,27 +69,34 @@ function BreakdownRow({ label, qty, unitPrice, cost }: { label: string; qty: num /** * The coins badge on an ingredient row. On click it opens a popover breaking down how the total amount - * needed would realistically be acquired on the bazaar (npc stock -> buy order -> insta-buy), with a - * toggle to switch between placing buy orders (patient, cheaper) and insta-buying everything (instant, - * pricier). When there is no market data it is just a plain badge. + * needed would realistically be acquired (NPC stock -> buy order -> insta-buy), with a toggle to switch + * between placing buy orders (patient, cheaper) and using NPC stock plus insta-buying the remainder + * (instant, pricier). When there is no market data it is just a plain badge. */ -function AcquisitionBadge({ ingredient, totalCount }: { ingredient: CraftingIngredient; totalCount: number }) { - let [mode, setMode] = useState('order') +function AcquisitionBadge({ ingredient, totalCount, preferredMode }: { ingredient: CraftingIngredient; totalCount: number; preferredMode: AcquisitionMode }) { + let [mode, setMode] = useState(preferredMode) + let plan = getAcquisitionPlan(ingredient, totalCount, mode) + let displayedCost = plan && plan.unmet === 0 ? plan.totalCost : ingredient.cost * (totalCount / Math.max(1, ingredient.count)) + + useEffect(() => { + setMode(preferredMode) + }, [preferredMode]) let coinsBadge = ( - Coins + Coins ) - let plan = getAcquisitionPlan(ingredient, totalCount, mode) if (!plan) { return coinsBadge } let popover = ( - Buy {numberWithThousandsSeparators(plan.totalCount)}× {ingredient.item.name} + + Buy {numberWithThousandsSeparators(plan.totalCount)}× {ingredient.item.name} + e.stopPropagation()}> setMode('order')} > - Buy orders + NPC + buy orders (~30 min) setMode('insta')} > - Insta-buy + NPC + insta-buy - - - + + +
Total @@ -126,16 +157,15 @@ function AcquisitionBadge({ ingredient, totalCount }: { ingredient: CraftingIngr )} - Estimated from the current order book.{' '} + The buy-order price uses the lower competitive market estimate. The insta-buy price uses the higher volume-weighted sell-offer estimate, + which represents Σ(quantity × price) ÷ total quantity across the order book instead of only the cheapest visible offer.{' '} {plan.order.qty > 0 - ? `A competitive buy order fills up to ${numberWithThousandsSeparators(ingredient.buyOrderCapacity || 0)} units at ~${formatUnitPrice( - plan.order.unitPrice - )}/unit (~30 min). ` + ? `Up to ${numberWithThousandsSeparators( + ingredient.buyOrderCapacity || 0 + )} units are expected to fill through a competitive buy order in about 30 minutes. ` : ''} {plan.insta.qty > 0 - ? `Sell offers start at ~${formatUnitPrice( - plan.insta.unitPrice - )}/unit - the real insta cost climbs as you buy deeper into the book.` + ? `The weighted instant estimate is ~${formatUnitPrice(plan.insta.unitPrice)}/unit and can move as the order book changes.` : ''} @@ -144,33 +174,43 @@ function AcquisitionBadge({ ingredient, totalCount }: { ingredient: CraftingIngr return ( - { e.stopPropagation() }} > - {coinsBadge} - + Coins · Compare costs + ) } export function IngredientList(props: Props) { let multiplier = props.multiplier || 1 + let acquisitionMode = props.acquisitionMode ?? 'order' return (
{props.ingredients.map((ingredient, i) => { let totalCount = ingredient.count * multiplier let copyCommand = getCopyCommand(ingredient, props.instructions) - let { craftSavingsPercent } = getCraftSavings(ingredient) + let path = getIngredientPath(props.pathPrefix ?? '', i, ingredient.item.tag) + let canCollapse = Boolean(ingredient.type === 'craft' && ingredient.ingredients?.length && props.onToggleIngredient) + let collapsed = canCollapse && props.collapsedPaths?.has(path) + let directBuyCost = getDirectBuyCost(ingredient, totalCount, acquisitionMode) + let subcraftCost = (ingredient.craftCost ?? ingredient.cost) * multiplier + let craftSavings = Math.max(0, directBuyCost - subcraftCost) + let craftSavingsPercent = directBuyCost > 0 ? (craftSavings / directBuyCost) * 100 : 0 return ( - <> +
{ props.onItemClick(ingredient) }} @@ -192,12 +232,31 @@ export function IngredientList(props: Props) { ) : null} {')'} - + {ingredient.type === 'craft' && ingredient.ingredients && ingredient.ingredients.length > 0 && ( - {craftSavingsPercent > 0 ? `Should be crafted · saves ~${Math.round(craftSavingsPercent)}%` : 'Should be crafted'} + Buy directly: Coins )} + {canCollapse && craftSavings > 0 ? ( + + Subcraft saves Coins (~{Math.round(craftSavingsPercent)}%) + + ) : null} + {canCollapse ? ( + + ) : null} { @@ -226,17 +285,21 @@ export function IngredientList(props: Props) {
- {ingredient.ingredients && ( -
+ {ingredient.ingredients && !collapsed && ( +
)} - + ) })}
diff --git a/components/ForgeFlips/ForgeFlips.module.css b/components/ForgeFlips/ForgeFlips.module.css new file mode 100644 index 000000000..6c166eb77 --- /dev/null +++ b/components/ForgeFlips/ForgeFlips.module.css @@ -0,0 +1,72 @@ +.header { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.metricGrid, +.detailsSummary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.metricGrid > div, +.detailsSummary > div { + display: flex; + flex-direction: column; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); +} + +.metricGrid span, +.detailsSummary span { + color: #adb5bd; + font-size: 0.8rem; +} + +.metricGrid strong, +.detailsSummary strong { + font-size: 0.95rem; + font-weight: 600; +} + +.metricGrid .primaryMetric { + background: rgba(25, 135, 84, 0.16); +} + +.primaryMetric strong { + color: #75d9a6; +} + +.hotm { + margin-top: 10px; + font-size: 0.9rem; +} + +.requirements, +.detailsRequirements { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.detailsSummary { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.detailsRequirements .requirements { + margin-top: 0; +} + +.filterHelp { + color: #adb5bd; +} + +@media (max-width: 480px) { + .metricGrid { + grid-template-columns: 1fr; + } +} diff --git a/components/ForgeFlips/ForgeFlips.tsx b/components/ForgeFlips/ForgeFlips.tsx index de039ac37..d0eaa7c5c 100644 --- a/components/ForgeFlips/ForgeFlips.tsx +++ b/components/ForgeFlips/ForgeFlips.tsx @@ -1,15 +1,19 @@ 'use client' -import { useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import Image from 'next/image' -import { Alert, Badge } from 'react-bootstrap' +import { Alert, Badge, Form } from 'react-bootstrap' import api from '../../api/ApiHelper' import { GenericFlipList, SortOption } from '../GenericFlipList' import NumberElement from '../Number/Number' -import { convertTagToName, getCraftSavings } from '../../utils/Formatter' -import { useGetApiFlipForge } from '../../api/_generated/skyApi' -import { ForgeFlip, ProfitableCraft } from '../../api/_generated/skyApi.schemas' +import { convertTagToName, toVariantItemTag } from '../../utils/Formatter' +import { useGetApiCraftProfit, useGetApiFlipForge } from '../../api/_generated/skyApi' +import { ForgeFlip, ProfitableCraft as GeneratedProfitableCraft } from '../../api/_generated/skyApi.schemas' import { getGeneratedApiErrorMessage, hasSuccessfulArrayResponse } from '../../utils/GeneratedApiResponseUtils' +import { parseProfitableCrafts } from '../../utils/Parser/APIResponseParser' +import { CostBreakdown } from '../CraftsList/CostBreakdown/CostBreakdown' +import Tooltip from '../Tooltip/Tooltip' +import styles from './ForgeFlips.module.css' const SORT_OPTIONS: SortOption[] = [ { @@ -39,7 +43,7 @@ const SORT_OPTIONS: SortOption[] = [ } ] -function getCraftProfit(craft?: ProfitableCraft | null) { +function getCraftProfit(craft?: GeneratedProfitableCraft | null) { if (!craft) { return 0 } @@ -87,109 +91,145 @@ function renderRequirements(requirements: ForgeFlip['requirements']) { return null } return ( -

- Requirements: - - {Object.entries(requirements).map(([key, value]) => ( - - {key}: {value} - - ))} - -

+
+ {Object.entries(requirements).map(([key, value]) => ( + + {convertTagToName(key)}: {value} + + ))} +
) } -function renderIngredients(craft?: ProfitableCraft | null) { - if (!craft?.ingredients || craft.ingredients.length === 0) { - return null - } +function getForgeHeader(flip: ForgeFlip) { + const tag = getForgeOutputTag(flip) + const imageUrl = tag ? api.getItemImageUrl({ tag }) : null return ( -
- Ingredients: -
    - {craft.ingredients.map(ingredient => { - const { isSubcraft, craftSavings: savings, craftSavingsPercent: savingsPercent } = getCraftSavings(ingredient) - return ( -
  • - {ingredient.count}x {ingredient.itemId ? convertTagToName(ingredient.itemId) : ingredient.type ?? 'Unknown'} - {ingredient.cost ? ( - - {' '} - ( Coins) - - ) : null} - {isSubcraft && savingsPercent > 0 ? ( - - 🔨 Subcraft · save ~{Math.round(savingsPercent)}% - {savings > 0 ? ( - <> - {' '} - ( Coins) - - ) : null} - - ) : null} -
  • - ) - })} -
-
+ + {imageUrl ? : null} + {getOutputName(flip)} + ) } function renderForgeFlip(flip: ForgeFlip) { const craft = flip.craftData - const tag = getForgeOutputTag(flip) - const imageUrl = tag ? api.getItemImageUrl({ tag }) : null const profit = getCraftProfit(craft) - const durationText = formatDuration(flip.duration) return ( <> -

- {imageUrl ? {getOutputName(flip)} : null} - {getOutputName(flip)} -

-

- Profit / hour: Coins -

-

- Craft profit: Coins -

-

- Median sell price: Coins -

-

- Craft cost: Coins -

-

- Volume: -

-

- Duration: {durationText} -

-

- Required HotM level: {flip.requiredHotMLevel} -

+

{getForgeHeader(flip)}

+
+
+ Profit / hour + + Coins + +
+
+ Forge profit + + Coins + +
+
+ Input cost + + Coins + +
+
+ Sell price + + Coins + +
+
+ Duration + {formatDuration(flip.duration)} +
+
+ Volume + + + +
+
+
Heart of the Mountain {flip.requiredHotMLevel}
{renderRequirements(flip.requirements)} - {renderIngredients(craft)} ) } +function ForgeDetails({ + flip, + craft, + isLoadingSubcrafts, + noWait, + onNoWaitChange +}: { + flip: ForgeFlip + craft?: ProfitableCraft + isLoadingSubcrafts: boolean + noWait: boolean + onNoWaitChange: (noWait: boolean) => void +}) { + const openIngredient = (ingredient: CraftingIngredient) => { + if (ingredient.type === 'craft' || ingredient.ingredients?.length) { + window.open(`/crafts?craft=${encodeURIComponent(ingredient.item.tag)}`, '_blank') + return + } + window.open(`/item/${toVariantItemTag(ingredient.item.tag)}?itemFilter=eyJCaW4iOiJ0cnVlIn0%3D`, '_blank') + } + + return ( +
+
+
+ Median sell price + + Coins + +
+
+ Forge time + {formatDuration(flip.duration)} +
+
+
+ Heart of the Mountain {flip.requiredHotMLevel} + {renderRequirements(flip.requirements)} +
+
+ +
+ ) +} + function filterFunction(flip: ForgeFlip, nameFilter: string | null | undefined, minimumProfit: number) { const nameMatch = !nameFilter || getOutputName(flip).toLowerCase().includes(nameFilter.toLowerCase()) const profitMatch = getCraftProfit(flip.craftData) >= minimumProfit return nameMatch && profitMatch } -function getFlipLink(flip: ForgeFlip) { - const tag = flip.craftData?.itemId - if (!tag) { - return undefined - } - return `https://sky.coflnet.com/item/${tag}` +function hasNoWaitInputs(flip: ForgeFlip, forgeTags: Set) { + return ( + flip.craftData?.ingredients?.length && + flip.craftData.ingredients.every(ingredient => { + const hasInstantSource = + (ingredient.buyOrderUnitPrice ?? 0) > 0 || + (ingredient.instaBuyUnitPrice ?? 0) > 0 || + ((ingredient as typeof ingredient & { npcCapacity?: number }).npcCapacity ?? 0) >= ingredient.count + return Boolean(hasInstantSource && ingredient.itemId && !forgeTags.has(ingredient.itemId)) + }) + ) } function censoredFlip(): ForgeFlip { @@ -210,10 +250,58 @@ function censoredFlip(): ForgeFlip { } export function ForgeFlips() { + const [loadSubcrafts, setLoadSubcrafts] = useState(false) + const [noWaitOnly, setNoWaitOnly] = useState(false) const query = useGetApiFlipForge() + const craftQuery = useGetApiCraftProfit(undefined, { query: { enabled: loadSubcrafts } }) const response = query.data const safeFlips = useMemo(() => (hasSuccessfulArrayResponse(response) ? response.data : []), [response]) + const craftResponse = craftQuery.data + const safeCrafts = useMemo(() => (hasSuccessfulArrayResponse(craftResponse) ? craftResponse.data : []), [craftResponse]) + const forgeTags = useMemo(() => new Set(safeFlips.flatMap(flip => (flip.craftData?.itemId ? [flip.craftData.itemId] : []))), [safeFlips]) + const expandedForgeCrafts = useMemo(() => { + const forgeCrafts = safeFlips.flatMap(flip => (flip.craftData?.itemId && flip.craftData.ingredients ? [flip.craftData] : [])) + const normalCrafts = safeCrafts.filter(craft => craft.type !== 'forge' && craft.itemId && craft.ingredients) + + return new Map(parseProfitableCrafts(normalCrafts, forgeCrafts).map(craft => [craft.item.tag, craft])) + }, [safeCrafts, safeFlips]) const errorMessage = getGeneratedApiErrorMessage(response, query.error, 'Unable to load forge flips right now') + const forgeFilterFunction = useCallback( + (flip: ForgeFlip, nameFilter: string | null | undefined, minimumProfit: number) => + filterFunction(flip, nameFilter, minimumProfit) && (!noWaitOnly || Boolean(hasNoWaitInputs(flip, forgeTags))), + [forgeTags, noWaitOnly] + ) + + function customItemWrapper(flip: ForgeFlip, blur: boolean, key: string, content: React.ReactNode, flipCardClass: string) { + if (blur) { + return ( +
+ {content} +
+ ) + } + + const tag = flip.craftData?.itemId + return ( + {content}} + tooltipTitle={getForgeHeader(flip)} + tooltipContent={ + + } + onClick={() => setLoadSubcrafts(true)} + /> + ) + } return (
@@ -256,13 +344,25 @@ export function ForgeFlips() { items={safeFlips} sortOptions={SORT_OPTIONS} renderFlipContentAction={renderForgeFlip} - filterFunction={filterFunction} + filterFunction={forgeFilterFunction} getItemKeyAction={flip => flip.craftData?.itemId ?? `forge-${flip.duration}-${flip.requiredHotMLevel}`} - getFlipLink={getFlipLink} censoredItemGenerator={censoredFlip} premiumMessage="The top 3 flips can only be seen with starter premium or better" - clickMessage="Click on a flip for further details" + clickMessage="Select a forge flip to see its inputs and the cheapest way to source them" showColumns + customItemWrapper={customItemWrapper} + customFilters={ +
+ setNoWaitOnly(event.target.checked)} + /> + Inputs must be instantly purchasable and cannot require another forge output. +
+ } /> )}
diff --git a/components/Premium/PremiumPurchaseWizard/Steps/DurationSelectionStep.tsx b/components/Premium/PremiumPurchaseWizard/Steps/DurationSelectionStep.tsx index 3392a4c5a..28fb7e457 100644 --- a/components/Premium/PremiumPurchaseWizard/Steps/DurationSelectionStep.tsx +++ b/components/Premium/PremiumPurchaseWizard/Steps/DurationSelectionStep.tsx @@ -111,7 +111,7 @@ export default function DurationSelectionStep({
{selectedTier === PremiumTier.STARTER && opt.value === Duration.QUARTER - ? `~€${roundUpToCent(getMonthlyPrice(opt.value)).toFixed(2)}${shouldIncludeVATInPrice() ? '' : ' (+VAT)'}/month` + ? `~€${roundUpToCent(getMonthlyPrice(opt.value)).toFixed(2)}${shouldIncludeVATInPrice() ? '' : ' (+VAT)'}/month · €${roundUpToCent(getPriceWithVAT(9.69)).toFixed(2)}${shouldIncludeVATInPrice() ? '' : ' (+VAT)'} total for 1,800 CoflCoins` : `~€${roundUpToCent(getMonthlyPrice(opt.value)).toFixed(2)}/month`}
diff --git a/components/Tooltip/Tooltip.tsx b/components/Tooltip/Tooltip.tsx index 64228ba0c..cea07a0e7 100644 --- a/components/Tooltip/Tooltip.tsx +++ b/components/Tooltip/Tooltip.tsx @@ -19,10 +19,11 @@ interface Props { className?: string id?: any hoverPlacement?: any + initiallyOpen?: boolean } function Tooltip(props: Props) { - let [showDialog, setShowDialog] = useState(false) + let [showDialog, setShowDialog] = useState(props.initiallyOpen ?? false) function getHoverElement() { return props.tooltipContent ? ( diff --git a/cypress/e2e/crafts.cy.ts b/cypress/e2e/crafts.cy.ts index 8c7b77d97..24dd393ad 100644 --- a/cypress/e2e/crafts.cy.ts +++ b/cypress/e2e/crafts.cy.ts @@ -13,6 +13,11 @@ describe('Profitable craft page', () => { cy.contains('You Cheated the').should('be.visible') cy.contains('button', 'Crafting Cost').last().click() cy.contains('h3', 'Recipe').should('be.visible') + cy.contains('h3', 'Combined shopping list').should('be.visible') + cy.contains('Potential profit').should('be.visible') cy.contains(/\)[\.,\d]* Coins.*/).should('be.visible') + cy.window().then(win => cy.stub(win, 'open').as('openCraft')) + cy.get('[data-ingredient-type="craft"]').first().click() + cy.get('@openCraft').should('have.been.calledWithMatch', /\/crafts\?craft=/, '_blank') }) -}) \ No newline at end of file +}) diff --git a/cypress/e2e/forge.cy.ts b/cypress/e2e/forge.cy.ts new file mode 100644 index 000000000..32c612530 --- /dev/null +++ b/cypress/e2e/forge.cy.ts @@ -0,0 +1,26 @@ +describe('Forge flip page', () => { + afterEach(() => { + // Prevents running into the rate limit + cy.wait(10000) + }) + + it('opens the forge input breakdown', { defaultCommandTimeout: 15000 }, () => { + cy.visit('/forge') + cy.contains('Select a forge flip to see its inputs and the cheapest way to source them').should('be.visible') + cy.contains('No-wait flips only').should('be.visible') + cy.get('.tooltipWrapper .list-group-item').first().click() + cy.contains('h3', 'Combined shopping list').should('be.visible') + cy.contains('h3', 'Recipe breakdown').should('be.visible') + cy.contains('Selected input cost').should('be.visible') + cy.contains('Potential profit').should('be.visible') + cy.contains('label', 'No-wait flips only — use NPC + insta-buy costs').click() + cy.get('#modal-no-wait-costs').should('be.checked') + cy.get('#forge-no-wait').should('be.checked') + cy.contains('button', 'Compare costs').first().click() + cy.contains('NPC + buy orders (~30 min)').should('be.visible') + cy.contains('NPC + insta-buy').should('be.visible') + cy.contains('NPC shop').should('be.visible') + cy.contains('label', 'NPC + insta-buy').click() + cy.contains('td', 'Buy order (~30 min)').parent().contains('Skipped') + }) +}) diff --git a/utils/CraftingUtils.ts b/utils/CraftingUtils.ts new file mode 100644 index 000000000..aeda1ae77 --- /dev/null +++ b/utils/CraftingUtils.ts @@ -0,0 +1,83 @@ +import { AcquisitionMode, getAcquisitionPlan } from './Formatter' + +export function getIngredientPath(parentPath: string, index: number, tag: string) { + return `${parentPath}${parentPath ? '/' : ''}${index}:${tag}` +} + +export function getMaxCraftDepth(ingredients: CraftingIngredient[]): number { + return ingredients.reduce((maxDepth, ingredient) => { + const childDepth = ingredient.ingredients?.length ? 1 + getMaxCraftDepth(ingredient.ingredients) : 0 + return Math.max(maxDepth, childDepth) + }, 0) +} + +export function limitCraftDepth(ingredients: CraftingIngredient[], depth: number): CraftingIngredient[] { + return ingredients.map(ingredient => { + if (!ingredient.ingredients?.length) { + return ingredient + } + if (depth <= 0) { + return { + ...ingredient, + cost: ingredient.buyOrderCost && ingredient.buyOrderCost > 0 ? ingredient.buyOrderCost : ingredient.cost, + ingredients: undefined + } + } + return { ...ingredient, ingredients: limitCraftDepth(ingredient.ingredients, depth - 1) } + }) +} + +export function getDirectBuyCost(ingredient: CraftingIngredient, totalCount: number, mode: AcquisitionMode = 'order') { + const plan = getAcquisitionPlan(ingredient, totalCount, mode) + if (plan && plan.unmet === 0) { + return plan.totalCost + } + return (ingredient.buyOrderCost && ingredient.buyOrderCost > 0 ? ingredient.buyOrderCost : ingredient.cost) * (totalCount / Math.max(1, ingredient.count)) +} + +export function getCombinedShoppingList( + ingredients: CraftingIngredient[], + collapsedPaths: Set = new Set(), + mode: AcquisitionMode = 'order' +): CraftingIngredient[] { + const combined = new Map() + + function addIngredients(items: CraftingIngredient[], multiplier = 1, parentPath = '') { + items.forEach((ingredient, index) => { + const path = getIngredientPath(parentPath, index, ingredient.item.tag) + const totalCount = ingredient.count * multiplier + if (ingredient.ingredients?.length && !collapsedPaths.has(path)) { + addIngredients(ingredient.ingredients, totalCount, path) + return + } + + const existing = combined.get(ingredient.item.tag) + const scaledCost = getDirectBuyCost(ingredient, totalCount, mode) + if (existing) { + existing.count += totalCount + existing.cost += scaledCost + return + } + + combined.set(ingredient.item.tag, { + ...ingredient, + count: totalCount, + cost: scaledCost, + type: undefined, + ingredients: undefined + }) + }) + } + + addIngredients(ingredients) + return Array.from(combined.values()) + .map(ingredient => { + const plan = getAcquisitionPlan(ingredient, ingredient.count, mode) + return plan && plan.unmet === 0 ? { ...ingredient, cost: plan.totalCost } : ingredient + }) + .sort((a, b) => (a.item.name ?? a.item.tag).localeCompare(b.item.name ?? b.item.tag)) +} + +export function getShoppingListCost(ingredients: CraftingIngredient[]) { + return ingredients.reduce((total, ingredient) => total + ingredient.cost, 0) +} diff --git a/utils/Formatter.tsx b/utils/Formatter.tsx index e8e0c1cea..4e6edb86c 100644 --- a/utils/Formatter.tsx +++ b/utils/Formatter.tsx @@ -102,8 +102,10 @@ export interface AcquisitionPlan { /** * Works out how a given total amount of an ingredient would realistically be acquired on the bazaar, - * cheapest channel first: npc stock, then either a competitive buy order ('order' mode) or straight to - * insta-buying sell offers ('insta' mode). The unit prices/capacities come from the backend and are + * cheapest channel first in 'order' mode: NPC stock, then a competitive buy order, then sell offers. + * 'insta' mode still uses immediately available NPC stock, skips buy orders, and prices the remainder + * from sell offers. + * The unit prices/capacities come from the backend and are * quantity independent, so this can be recomputed for any tree-multiplied total. Returns null when there * is no market data at all. Costs are estimates - the insta portion in particular assumes the sell book * stays near its current marginal price rather than walking deeper as it is consumed. @@ -122,8 +124,11 @@ export function getAcquisitionPlan( const npcCap = Math.max(0, ingredient.npcCapacity ?? 0) const npcUnit = ingredient.npcUnitPrice ?? 0 const orderCap = Math.max(0, ingredient.buyOrderCapacity ?? 0) - const orderUnit = ingredient.buyOrderUnitPrice ?? 0 - const instaUnit = ingredient.instaBuyUnitPrice ?? 0 + // The backend's two historical field names are inconsistent across bazaar items. Preserve the + // market invariant: a patient buy order uses the lower estimate and instant execution the higher. + const marketPrices = [ingredient.buyOrderUnitPrice ?? 0, ingredient.instaBuyUnitPrice ?? 0].filter(price => price > 0) + const orderUnit = orderCap > 0 && marketPrices.length > 0 ? Math.min(...marketPrices) : 0 + const instaUnit = marketPrices.length > 0 ? Math.max(...marketPrices) : 0 if (npcCap <= 0 && orderCap <= 0 && instaUnit <= 0) { return null // no market data } @@ -134,8 +139,6 @@ export function getAcquisitionPlan( const npcQty = Math.min(remaining, npcCap) remaining -= npcQty - // In 'order' mode we patiently buy-order the middle bucket; in 'insta' mode we skip it and take - // everything beyond npc stock straight from the sell offers. const orderQty = mode === 'order' ? Math.min(remaining, orderCap) : 0 remaining -= orderQty diff --git a/utils/Parser/APIResponseParser.tsx b/utils/Parser/APIResponseParser.tsx index e03e8da43..dfe9017c5 100644 --- a/utils/Parser/APIResponseParser.tsx +++ b/utils/Parser/APIResponseParser.tsx @@ -378,7 +378,7 @@ export function parseDate(dateString: string) { return new Date(dateString + 'Z') } -export function parseProfitableCrafts(crafts: any[] = []): ProfitableCraft[] { +export function parseProfitableCrafts(crafts: any[] = [], craftsToParse: any[] = crafts): ProfitableCraft[] { const parseCraftIngredient = (ingredient: any, seenTags: Set): CraftingIngredient => { const result = { cost: ingredient.cost, @@ -424,7 +424,7 @@ export function parseProfitableCrafts(crafts: any[] = []): ProfitableCraft[] { return result } - return crafts.map(craft => { + return craftsToParse.map(craft => { let c = { item: { tag: craft.itemId,