Skip to content

Commit b3b9978

Browse files
Adding crafting ingredients lists
Added collapsed-by-default "Needed Ingredients" sections to cooking and crafting pages with support for filtering by recipe state and season. **Motivation and goals:** Crafting every item requires a tremendous number of ingredients, some of which can only be obtained in certain seasons. Having a full list to reference makes it easy to plan ahead. Being able to filter by season makes near term planning much more tractable. **Features:** - Support for cooking and crafting recipes - Expands recipe ingredients recursively to make planning easier - Familiar card layouts with Wiki links - Custom Wiki links for non-specific ingredients (e.g. "Any Fish") - Filtering by known/unknown (crafted/cooked excluded to avoid clutter) - Filtering by season
1 parent 5a38ba9 commit b3b9978

File tree

4 files changed

+561
-0
lines changed

4 files changed

+561
-0
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import Image from "next/image";
2+
3+
import bigCraftables from "@/data/big_craftables.json";
4+
import objects from "@/data/objects.json";
5+
6+
import { cn } from "@/lib/utils";
7+
import { Dispatch, SetStateAction, useState } from "react";
8+
9+
import { deweaponize } from "@/lib/utils";
10+
11+
import { NewItemBadge } from "@/components/new-item-badge";
12+
import { Button } from "@/components/ui/button";
13+
import {
14+
Dialog,
15+
DialogContent,
16+
DialogDescription,
17+
DialogFooter,
18+
DialogHeader,
19+
DialogTitle,
20+
DialogTrigger,
21+
} from "@/components/ui/dialog";
22+
23+
import { IconChevronRight, IconExternalLink } from "@tabler/icons-react";
24+
25+
interface Props {
26+
/**
27+
* The ID of the object/big-craftable/category to display
28+
*/
29+
itemID: string;
30+
31+
/**
32+
* Number to display as the needed count in the card
33+
*/
34+
count: number;
35+
36+
/**
37+
* Whether the user prefers to see new content
38+
*
39+
* @type {boolean}
40+
* @memberof Props
41+
*/
42+
show: boolean;
43+
44+
/**
45+
* The handler to display the new content confirmation prompt
46+
*
47+
* @type {Dispatch<SetStateAction<boolean>>}
48+
* @memberof Props
49+
*/
50+
setPromptOpen?: Dispatch<SetStateAction<boolean>>;
51+
}
52+
53+
interface Item {
54+
isCategory: boolean;
55+
isBC: boolean;
56+
minVersion: string;
57+
name: string;
58+
iconURL: string;
59+
description?: string;
60+
wikiName: string;
61+
}
62+
63+
const categoryItems: Record<string, string> = {
64+
"-4": "Any Fish",
65+
"-5": "Any Egg",
66+
"-6": "Any Milk",
67+
"-777": "Wild Seeds (Any)",
68+
};
69+
70+
const categoryWikiNames: Record<string, string> = {
71+
"-4": "Fish",
72+
"-5": "Egg",
73+
"-6": "Milk",
74+
"-777": "Wild_Seeds",
75+
};
76+
77+
export function IngredientMinVersion(itemID: string): string {
78+
if (itemID.startsWith("-")) {
79+
return "1.5.0";
80+
} else if (deweaponize(itemID).key === "BC") {
81+
const item_id = deweaponize(itemID).value;
82+
return bigCraftables[item_id as keyof typeof bigCraftables].minVersion;
83+
}
84+
85+
return objects[itemID as keyof typeof objects].minVersion;
86+
}
87+
88+
function GetItemDetails(itemID: string): Item {
89+
// if itemID is less than 0, it's a category
90+
if (itemID.startsWith("-")) {
91+
return {
92+
isCategory: true,
93+
isBC: false,
94+
minVersion: "1.5.0",
95+
name: categoryItems[itemID],
96+
iconURL: `https://cdn.stardew.app/images/(C)${itemID}.webp`,
97+
wikiName: categoryWikiNames[itemID],
98+
};
99+
} else if (deweaponize(itemID).key === "BC") {
100+
const item_id = deweaponize(itemID).value;
101+
let item = bigCraftables[item_id as keyof typeof bigCraftables];
102+
103+
return {
104+
isCategory: false,
105+
isBC: true,
106+
minVersion: item.minVersion,
107+
name: item.name,
108+
iconURL: `https://cdn.stardew.app/images/(BC)${deweaponize(itemID).value}.webp`,
109+
description: item.description,
110+
wikiName: item.name.replaceAll(" ", "_"),
111+
};
112+
} else {
113+
let item = objects[itemID as keyof typeof objects];
114+
115+
return {
116+
isCategory: false,
117+
isBC: false,
118+
minVersion: item.minVersion,
119+
name: item.name,
120+
iconURL: `https://cdn.stardew.app/images/(O)${itemID}.webp`,
121+
description: item.description ?? undefined,
122+
wikiName: item.name.replaceAll(" ", "_"),
123+
};
124+
}
125+
}
126+
127+
export const IngredientCard = ({
128+
itemID,
129+
count,
130+
show,
131+
setPromptOpen,
132+
}: Props) => {
133+
const [open, setOpen] = useState(false);
134+
135+
let item = GetItemDetails(itemID);
136+
137+
return (
138+
<Dialog open={open} onOpenChange={setOpen}>
139+
<DialogTrigger asChild>
140+
<div
141+
className={cn(
142+
"relative flex select-none items-center justify-between rounded-lg border px-5 py-4 text-left text-neutral-950 shadow-sm transition-colors hover:cursor-pointer dark:text-neutral-50",
143+
"border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-800",
144+
)}
145+
onClick={(e) => {
146+
if (item.minVersion === "1.6.0" && !show) {
147+
e.preventDefault();
148+
setPromptOpen?.(true);
149+
return;
150+
}
151+
}}
152+
>
153+
{item.minVersion === "1.6.0" && (
154+
<NewItemBadge version={item.minVersion} />
155+
)}
156+
<div
157+
className={cn(
158+
"flex items-center space-x-3 truncate text-left",
159+
item.minVersion === "1.6.0" && !show && "blur-sm",
160+
)}
161+
>
162+
<Image
163+
src={item.iconURL}
164+
alt={item.name}
165+
className="rounded-sm"
166+
width={32}
167+
height={32}
168+
/>
169+
<div className="min-w-0 flex-1 pr-3">
170+
<p className="truncate font-medium">{`${item.name} (${count}x)`}</p>
171+
<p className="truncate text-sm text-neutral-500 dark:text-neutral-400">
172+
{item.description ?? ""}
173+
</p>
174+
</div>
175+
</div>
176+
<IconChevronRight className="h-5 w-5 flex-shrink-0 text-neutral-500 dark:text-neutral-400" />
177+
</div>
178+
</DialogTrigger>
179+
<DialogContent>
180+
<DialogHeader>
181+
<Image
182+
src={item.iconURL}
183+
alt={item.name}
184+
className="mx-auto rounded-sm"
185+
width={42}
186+
height={42}
187+
/>
188+
<DialogTitle className="text-center">{item.name}</DialogTitle>
189+
<DialogDescription className="text-center">
190+
{item.description}
191+
</DialogDescription>
192+
</DialogHeader>
193+
<DialogFooter className="gap-3 sm:justify-between sm:gap-0">
194+
<Button variant="outline">
195+
<a
196+
className="flex items-center"
197+
target="_blank"
198+
rel="noreferrer"
199+
href={`https://stardewvalleywiki.com/${item.wikiName}`}
200+
>
201+
Visit Wiki Page
202+
<IconExternalLink className="h-4"></IconExternalLink>
203+
</a>
204+
</Button>
205+
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end sm:gap-0 sm:space-x-2">
206+
<Button variant="secondary" onClick={() => setOpen(false)}>
207+
Close
208+
</Button>
209+
</div>
210+
</DialogFooter>
211+
</DialogContent>
212+
</Dialog>
213+
);
214+
};
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import shipping_items from "@/data/shipping.json";
2+
3+
import type { Recipe } from "@/types/recipe";
4+
5+
import { usePlayers } from "@/contexts/players-context";
6+
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
7+
import { IngredientCard, IngredientMinVersion } from "./cards/ingredient-card";
8+
9+
const semverGte = require("semver/functions/gte");
10+
11+
interface Props<T extends Recipe> {
12+
/**
13+
* All of the recipes available within the game
14+
*/
15+
recipes: {
16+
[key: string]: T;
17+
};
18+
19+
/**
20+
* Player's recipe knowledge
21+
*/
22+
playerRecipes: {
23+
[key: string]: 0 | 1 | 2;
24+
};
25+
26+
/**
27+
* Whether to limit ingredients counts to unkown ("0"), known ("1"), or
28+
* "all" recipes.
29+
*/
30+
filterKnown?: string;
31+
32+
/**
33+
* Limit shown ingredients to those available in a particular season, or
34+
* "all" ingredients.
35+
*/
36+
filterSeason?: string;
37+
38+
/**
39+
* Whether the user prefers to see new content
40+
*
41+
* @type {boolean}
42+
* @memberof Props
43+
*/
44+
show: boolean;
45+
46+
/**
47+
* The handler to display the new content confirmation prompt
48+
*
49+
* @type {Dispatch<SetStateAction<boolean>>}
50+
* @memberof Props
51+
*/
52+
setPromptOpen?: Dispatch<SetStateAction<boolean>>;
53+
}
54+
55+
class IngredientData {
56+
counts: [number, number, number] = [0, 0, 0];
57+
seasons: string[] = [];
58+
59+
constructor(itemID: string) {
60+
if (itemID in shipping_items) {
61+
this.seasons =
62+
shipping_items[itemID as keyof typeof shipping_items].seasons;
63+
}
64+
}
65+
}
66+
67+
type IngredientsRecord = Record<string, IngredientData>;
68+
69+
export const IngredientList = <T extends Recipe>({
70+
recipes,
71+
playerRecipes,
72+
filterKnown = "",
73+
filterSeason = "all",
74+
setPromptOpen,
75+
show,
76+
}: Props<T>) => {
77+
const [gameVersion, setGameVersion] = useState("1.6.0");
78+
79+
const { activePlayer } = usePlayers();
80+
81+
useEffect(() => {
82+
if (activePlayer) {
83+
// set the minimum game version
84+
if (activePlayer.general?.gameVersion) {
85+
const version = activePlayer.general.gameVersion;
86+
setGameVersion(version);
87+
}
88+
}
89+
}, [activePlayer]);
90+
91+
const ingredientCounts: IngredientsRecord = useMemo(() => {
92+
const reduceIngredients = (
93+
acc: IngredientsRecord,
94+
[_, v]: [string, T],
95+
status: 0 | 1 | 2,
96+
): IngredientsRecord =>
97+
v.ingredients.reduce((a, i) => {
98+
if (!(i.itemID in a)) {
99+
a[i.itemID] = new IngredientData(i.itemID);
100+
}
101+
102+
a[i.itemID].counts[status] += i.quantity;
103+
104+
if (i.itemID in recipes) {
105+
a = reduceIngredients(a, [i.itemID, recipes[i.itemID]], status);
106+
}
107+
108+
return a;
109+
}, acc);
110+
111+
return Object.entries(recipes).reduce(
112+
(acc, [id, v]) =>
113+
reduceIngredients(acc, [id, v], playerRecipes[v.itemID] ?? 0),
114+
{},
115+
);
116+
}, [recipes, playerRecipes]);
117+
118+
return (
119+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
120+
{Object.entries(ingredientCounts)
121+
.filter(([id, _]) => semverGte(gameVersion, IngredientMinVersion(id)))
122+
.filter(([_, details]) => {
123+
if (filterSeason === "all") {
124+
return true;
125+
}
126+
127+
return (
128+
details.seasons.length == 0 ||
129+
details.seasons.includes(filterSeason)
130+
);
131+
})
132+
.map(([id, details]): [string, number] => {
133+
switch (filterKnown) {
134+
case "0":
135+
return [id, details.counts[0]];
136+
case "1":
137+
return [id, details.counts[1]];
138+
case "2":
139+
return [id, 0];
140+
default:
141+
return [id, details.counts[0] + details.counts[1]];
142+
}
143+
})
144+
.filter(([_, count]) => count > 0)
145+
.map(([id, count]) => (
146+
<IngredientCard
147+
key={id}
148+
itemID={id}
149+
count={count}
150+
show={show}
151+
setPromptOpen={setPromptOpen}
152+
/>
153+
))}
154+
</div>
155+
);
156+
};

0 commit comments

Comments
 (0)