Adds crafted items, weapon config, generally working

This commit is contained in:
2025-08-22 13:11:47 -07:00
parent 87c908ca68
commit 7556fade3d
18 changed files with 431 additions and 71 deletions

View File

@@ -4,7 +4,8 @@ import { Equipment } from "./components/Equipment";
import { SourceList } from "./components/SourceList"; import { SourceList } from "./components/SourceList";
import { StateContext } from "./lib/context/StateContext"; import { StateContext } from "./lib/context/StateContext";
import { reducer, withLoadingAction } from "./lib/state"; import { reducer, withLoadingAction } from "./lib/state";
import type { ItemId } from "./lib/types"; import { WeaponConfig, type ItemId } from "./lib/types";
import { WeaponConfigPicker } from "./components/WeaponConfigPicker";
function App() { function App() {
const [state, dispatch] = useReducer(withLoadingAction(reducer), "loading"); const [state, dispatch] = useReducer(withLoadingAction(reducer), "loading");
@@ -25,6 +26,7 @@ function App() {
state: { state: {
equipedItems: [{ id: 219309 as ItemId, quality: "champion" }], equipedItems: [{ id: 219309 as ItemId, quality: "champion" }],
bisList: [], bisList: [],
weaponConfig: WeaponConfig.TwoHander,
}, },
}); });
} }
@@ -46,11 +48,15 @@ function App() {
}} }}
> >
<h1>WoW Gear Finder</h1> <h1>WoW Gear Finder</h1>
<h2>Config</h2>
<WeaponConfigPicker />
<h2>Equipment</h2>
<Equipment <Equipment
state={state} state={state}
onEquip={(item) => dispatch({ action: "equipItem", item })} onEquip={(item) => dispatch({ action: "equipItem", item })}
onUnequip={(item) => dispatch({ action: "unequipItem", item })} onUnequip={(item) => dispatch({ action: "unequipItem", item })}
/> />
<h2>Upgrades</h2>
<SourceList state={state} /> <SourceList state={state} />
</StateContext.Provider> </StateContext.Provider>
); );

View File

@@ -1,4 +1,4 @@
import { ItemsById } from "../lib/items"; import { ItemsById, itemsPerSlot } from "../lib/items";
import type { State } from "../lib/state"; import type { State } from "../lib/state";
import { Slots, type Item, type Slot } from "../lib/types"; import { Slots, type Item, type Slot } from "../lib/types";
import { ItemLink } from "./ItemLink"; import { ItemLink } from "./ItemLink";
@@ -12,16 +12,28 @@ export type Props = {
onUnequip: (item: Item) => void; onUnequip: (item: Item) => void;
}; };
export const Equipment = ({ state, onEquip, onUnequip }: Props) => ( export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
return (
<div> <div>
<ItemTypeahead value={null} onSelect={onEquip} /> <ItemTypeahead value={null} onSelect={(item) => item && onEquip(item)} />
<div className={styles.equipedItems}> <div className={styles.equipedItems}>
{Slots.map((slot) => ( {Slots.map((slot) => {
<Slot state={state} slot={slot} onUnequip={onUnequip} key={slot} /> if (itemsPerSlot(slot, state.weaponConfig) > 0) {
))} return (
<Slot
state={state}
slot={slot}
onUnequip={onUnequip}
key={slot}
/>
);
}
return null;
})}
</div> </div>
</div> </div>
); );
};
type SlotProps = { type SlotProps = {
state: State; state: State;

View File

@@ -0,0 +1,7 @@
.itemLink {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 20px;
}

View File

@@ -1,4 +1,5 @@
import type { Item } from "../lib/types"; import type { Item } from "../lib/types";
import * as styles from "./ItemLink.module.css";
export type Props = { export type Props = {
item: Item; item: Item;
@@ -9,7 +10,13 @@ export const ItemLink = ({ item }: Props) => (
href={`https://www.wowhead.com/item=${item.id}`} href={`https://www.wowhead.com/item=${item.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={styles.itemLink}
> >
{item.icon && (
<img
src={`https://wow.zamimg.com/images/wow/icons/medium/${item.icon}.jpg`}
/>
)}
{item.name} {item.name}
</a> </a>
); );

View File

@@ -5,8 +5,8 @@ import {
ComboboxOptions, ComboboxOptions,
} from "@headlessui/react"; } from "@headlessui/react";
import { useState } from "react"; import { useState } from "react";
import { AllItems } from "../lib/items";
import type { Item } from "../lib/types"; import type { Item } from "../lib/types";
import { AllItems } from "../lib/drops";
export type Props = { export type Props = {
value: Item | null; value: Item | null;

View File

@@ -16,9 +16,9 @@ export const QualitySelector = ({ item }: Props) => {
}); });
}; };
return ( return (
<select onChange={handleOnChange}> <select onChange={handleOnChange} value={item.quality}>
{Object.values(Quality).map((quality) => ( {Object.values(Quality).map((quality) => (
<option value={quality} selected={item.quality === quality}> <option value={quality}>
{quality.charAt(0).toUpperCase() + quality.slice(1)} {quality.charAt(0).toUpperCase() + quality.slice(1)}
</option> </option>
))} ))}

View File

@@ -3,3 +3,7 @@
gap: 8px; gap: 8px;
grid-template-columns: 1fr 6em 6em 6em; grid-template-columns: 1fr 6em 6em 6em;
} }
.itemList {
grid-column: 1 / span 4;
}

View File

@@ -1,4 +1,5 @@
import * as _ from "lodash"; import _ from "lodash";
import { useState } from "react";
import { getUpgrades, hasUpgradeType } from "../lib/items"; import { getUpgrades, hasUpgradeType } from "../lib/items";
import type { State } from "../lib/state"; import type { State } from "../lib/state";
import { import {
@@ -7,6 +8,7 @@ import {
type Source, type Source,
type Upgrade, type Upgrade,
} from "../lib/types"; } from "../lib/types";
import { ItemLink } from "./ItemLink";
import styles from "./SourceList.module.css"; import styles from "./SourceList.module.css";
export type Props = { export type Props = {
@@ -14,12 +16,12 @@ export type Props = {
}; };
export const SourceList = ({ state }: Props) => { export const SourceList = ({ state }: Props) => {
const upgrades = getUpgrades(state.equipedItems); const upgrades = getUpgrades(state.equipedItems, state.weaponConfig);
const upgradesBySource: Record<Source, Upgrade[]> = _.groupBy( const upgradesBySource = _.groupBy(
upgrades, upgrades,
(upgrade: { item: Item }) => upgrade.item.source, (upgrade: { item: Item }) => upgrade.item.source,
); ) as Record<Source, Upgrade[]>;
return ( return (
<div className={styles.sourceList}> <div className={styles.sourceList}>
@@ -28,22 +30,74 @@ export const SourceList = ({ state }: Props) => {
<div>Hero</div> <div>Hero</div>
<div>Myth</div> <div>Myth</div>
{Object.entries(upgradesBySource).map( {Object.entries(upgradesBySource).map(
([source, items]: [string, Upgrade[]]) => { ([source, items]: [string, Upgrade[]]) => (
const champion = items.filter( <SourceInfo source={source as Source} items={items} key={source} />
hasUpgradeType(UpgradeType.Champion), ),
).length;
const hero = items.filter(hasUpgradeType(UpgradeType.Hero)).length;
const myth = items.filter(hasUpgradeType(UpgradeType.Myth)).length;
return (
<>
<div>{source}</div>
<div>{champion}</div>
<div>{hero}</div>
<div>{myth}</div>
</>
);
},
)} )}
</div> </div>
); );
}; };
type SourceInfoProps = {
source: Source;
items: Upgrade[];
};
const SourceInfo = ({ source, items }: SourceInfoProps) => {
const [upgradeTypeShown, setUpgradeTypeShown] = useState<UpgradeType | null>(
null,
);
const championUpgrades = items.filter(hasUpgradeType(UpgradeType.Champion));
const heroUpgrades = items.filter(hasUpgradeType(UpgradeType.Hero));
const mythUpgrades = items.filter(hasUpgradeType(UpgradeType.Myth));
const toggleIsOpen = (upgradeType: UpgradeType) => {
if (upgradeTypeShown === upgradeType) {
setUpgradeTypeShown(null);
}
setUpgradeTypeShown(upgradeType);
};
return (
<>
<div>{source}</div>
<div onClick={() => toggleIsOpen(UpgradeType.Champion)}>
{championUpgrades.length}
</div>
<div onClick={() => toggleIsOpen(UpgradeType.Hero)}>
{heroUpgrades.length}
</div>
<div onClick={() => toggleIsOpen(UpgradeType.Myth)}>
{mythUpgrades.length}
</div>
{upgradeTypeShown === UpgradeType.Champion && (
<div className={styles.itemList}>
{championUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} /> ({upgrade.item.slot})
</div>
))}
</div>
)}
{upgradeTypeShown === UpgradeType.Hero && (
<div className={styles.itemList}>
{heroUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} />
</div>
))}
</div>
)}
{upgradeTypeShown === UpgradeType.Myth && (
<div className={styles.itemList}>
{mythUpgrades.map((upgrade) => (
<div>
<ItemLink item={upgrade.item} />
</div>
))}
</div>
)}
</>
);
};

View File

@@ -0,0 +1,28 @@
import { useAppState } from "../lib/context/StateContext";
import { WeaponConfig } from "../lib/types";
export const WeaponConfigPicker = () => {
const { state, dispatch } = useAppState();
const handleOnChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value?.trim();
if (value && value.length > 0) {
dispatch({
action: "changeWeaponConfig",
weaponConfig: value as WeaponConfig,
});
}
};
return (
<div>
<select onChange={handleOnChange} value={state.weaponConfig}>
{Object.values(WeaponConfig).map((config) => (
<option key={config} value={config}>
{config}
</option>
))}
</select>
</div>
);
};

View File

@@ -12,6 +12,7 @@ export const Aldani = [
slot: "trinket", slot: "trinket",
name: "Incorporeal Warpclaw", name: "Incorporeal Warpclaw",
source: "aldani" as const, source: "aldani" as const,
icon: "inv_misc_nightsaberclaw_mana",
}, },
{ {
id: 242481 as ItemId, id: 242481 as ItemId,

16
src/lib/drops/crafted.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Item } from "../types";
export const Crafted: Item[] = [
{
id: 219334,
name: "Rune-Branded Armguards",
slot: "wrist",
source: "crafted",
},
{
id: 219502,
name: "Adrenal Surge Clasp",
slot: "waist",
source: "crafted",
},
] as Item[];

View File

@@ -73,4 +73,18 @@ export const Dawnbreaker = [
name: "Bludgeons of Blistering Wind", name: "Bludgeons of Blistering Wind",
source: "dawnbreaker" as const, source: "dawnbreaker" as const,
}, },
{
id: 221136,
slot: "finger",
name: "Devout Zealot's Ring",
source: "dawnbreaker" as const,
icon: "inv_11_0_nerubian_ring_01_color4",
},
{
id: 221141,
slot: "finger",
name: "High Nerubian Signet",
source: "dawnbreaker" as const,
icon: "inv_11_0_nerubian_ring_02_color5",
},
] as Item[]; ] as Item[];

View File

@@ -7,6 +7,12 @@ export const Floodgate = [
name: "Improvised Seaforium Pacemaker", name: "Improvised Seaforium Pacemaker",
source: "floodgate" as const, source: "floodgate" as const,
}, },
{
id: 232543 as ItemId,
slot: "trinket",
name: "Ringing Ritual Mud",
source: "floodgate" as const,
},
{ {
id: 234499 as ItemId, id: 234499 as ItemId,
slot: "wrist", slot: "wrist",

View File

@@ -1,8 +1,23 @@
export { Aldani } from "./aldani"; import type { Item } from "../types";
export { AraKara } from "./ara-kara"; import { Aldani } from "./aldani";
export { Dawnbreaker } from "./dawnbreaker"; import { AraKara } from "./ara-kara";
export { Floodgate } from "./floodgate"; import { Crafted } from "./crafted";
export { HallsOfAtonement } from "./halls-of-atonement"; import { Dawnbreaker } from "./dawnbreaker";
export { PrioryOfTheSacredFlame } from "./priory"; import { Floodgate } from "./floodgate";
export { Streets, Gambit } from "./tazavesh"; import { HallsOfAtonement } from "./halls-of-atonement";
export { ManaforgeOmega } from "./manaforge-omega"; import { ManaforgeOmega } from "./manaforge-omega";
import { PrioryOfTheSacredFlame } from "./priory";
import { Streets, Gambit } from "./tazavesh";
export const AllItems: Item[] = [
...PrioryOfTheSacredFlame,
...Aldani,
...AraKara,
...Dawnbreaker,
...Floodgate,
...HallsOfAtonement,
...Streets,
...Gambit,
...ManaforgeOmega,
...Crafted,
];

View File

@@ -37,4 +37,167 @@ export const ManaforgeOmega = [
slot: "back", slot: "back",
source: "manaforge omega", source: "manaforge omega",
}, },
{
id: 242395,
name: "Astral Antenna",
slot: "trinket",
source: "manaforge omega",
},
{
id: 243306,
name: "Interloper's Reinforced Sandals",
slot: "feet",
source: "manaforge omega",
},
{
id: 242397,
name: "Sigil of the Cosmic Hunt",
slot: "trinket",
source: "manaforge omega",
},
{
id: 242394,
name: "Eradicating Arcanocore",
slot: "trinket",
source: "manaforge omega",
},
{
id: 237562,
name: "Time-Compressed Wristguards",
slot: "wrist",
source: "manaforge omega",
},
{
id: 237739,
name: "Obliteration Beamglaive",
slot: "2h-weapon",
source: "manaforge omega",
},
{
id: 242391,
name: "Soulbinder's Embrace",
slot: "trinket",
source: "manaforge omega",
},
{
id: 237734,
name: "Oath-Breaker's Recompense",
slot: "1h-weapon",
source: "manaforge omega",
},
{
id: 237726,
name: "Marvel of Technomancy",
slot: "2h-weapon",
source: "manaforge omega",
},
{
id: 237533,
name: "Atomic Phasebelt",
slot: "waist",
source: "manaforge omega",
},
{
id: 237738,
name: "Unbound Training Claws",
slot: "1h-weapon",
source: "manaforge omega",
},
{
id: 238027,
name: "Harvested Creephide Cord",
slot: "waist",
source: "manaforge omega",
},
{
id: 238031,
name: "Veiled Manta Vest",
slot: "chest",
source: "manaforge omega",
},
{
id: 237813,
name: "Factory-Issue Plexhammer",
slot: "1h-weapon",
source: "manaforge omega",
},
{
id: 237565,
name: "Kinetic Dunerunners",
slot: "feet",
source: "manaforge omega",
},
{
id: 237525,
name: "Irradiated Impurity Filter",
slot: "head",
source: "manaforge omega",
},
{
id: 237541,
name: "Darksorrow's Corrupted Carapace",
slot: "chest",
source: "manaforge omega",
},
{
id: 237731,
name: "Ergospheric Cudgel",
slot: "1h-weapon",
source: "manaforge omega",
},
{
id: 237552,
name: "Deathbound Shoulderpads",
slot: "shoulder",
source: "manaforge omega",
},
{
id: 237546,
name: "Bindings of Lost Essence",
slot: "wrist",
source: "manaforge omega",
},
{
id: 237540,
name: "Winged Gamma Handlers",
slot: "hands",
source: "manaforge omega",
},
{
id: 237557,
name: "Reaper's Dreadbelt",
slot: "waist",
source: "manaforge omega",
},
{
id: 237553,
name: "Laboratory Test Slippers",
slot: "feet",
source: "manaforge omega",
},
{
id: 237531,
name: "Elite Shadowguard Legwraps",
slot: "legs",
source: "manaforge omega",
},
{
id: 230026,
name: "Scrapfield 9001",
slot: "trinket",
source: "manaforge omega",
},
{
id: 228854,
name: "Bilgerat's Discarded Slacks",
slot: "legs",
source: "manaforge omega",
},
{
id: 242401,
name: "Brand of Ceaseless Ire",
slot: "trinket",
source: "manaforge omega",
icon: "inv_112_raidtrinkets_manaforgetanktrinket3",
},
] as const; ] as const;

View File

@@ -1,6 +1,7 @@
import _ from "lodash"; import _ from "lodash";
import { import {
UpgradeType, UpgradeType,
WeaponConfig,
type EquipedItem, type EquipedItem,
type Item, type Item,
type ItemId, type ItemId,
@@ -8,29 +9,7 @@ import {
type Slot, type Slot,
type Upgrade, type Upgrade,
} from "./types"; } from "./types";
import { import { AllItems } from "./drops";
Aldani,
AraKara,
Dawnbreaker,
Floodgate,
HallsOfAtonement,
PrioryOfTheSacredFlame,
Streets,
Gambit,
ManaforgeOmega,
} from "./drops";
export const AllItems: Item[] = [
...PrioryOfTheSacredFlame,
...Aldani,
...AraKara,
...Dawnbreaker,
...Floodgate,
...HallsOfAtonement,
...Streets,
...Gambit,
...ManaforgeOmega,
];
export const ItemsById: Record<string, Item> = AllItems.reduce( export const ItemsById: Record<string, Item> = AllItems.reduce(
(db, item) => { (db, item) => {
@@ -87,7 +66,10 @@ export const upgradesForItem = ({ quality }: { quality: Quality }) => {
} }
}; };
export const getUpgrades = (equipedItemIds: EquipedItem[]): Upgrade[] => { export const getUpgrades = (
equipedItemIds: EquipedItem[],
weaponConfig: WeaponConfig,
): Upgrade[] => {
return Object.entries(ItemsBySlot).flatMap(([slot, items]) => { return Object.entries(ItemsBySlot).flatMap(([slot, items]) => {
const equipedItems = equipedItemIds.flatMap(({ id, quality }) => ({ const equipedItems = equipedItemIds.flatMap(({ id, quality }) => ({
item: ItemsById[id], item: ItemsById[id],
@@ -96,7 +78,16 @@ export const getUpgrades = (equipedItemIds: EquipedItem[]): Upgrade[] => {
const equipedInSlot = equipedItems.filter( const equipedInSlot = equipedItems.filter(
(item) => item.item.slot === slot, (item) => item.item.slot === slot,
); );
const itemsForSlot = itemsPerSlot(slot as Slot);
const itemsForSlot = itemsPerSlot(slot as Slot, weaponConfig);
if (itemsForSlot === 0) {
console.log(
`Skipping ${slot} because it has no items with ${weaponConfig}`,
);
return [];
}
const numChampion = equipedInSlot.filter(qualityAtLeast("champion")).length; const numChampion = equipedInSlot.filter(qualityAtLeast("champion")).length;
const numHero = equipedInSlot.filter(qualityAtLeast("hero")).length; const numHero = equipedInSlot.filter(qualityAtLeast("hero")).length;
const numMyth = equipedInSlot.filter(qualityAtLeast("myth")).length; const numMyth = equipedInSlot.filter(qualityAtLeast("myth")).length;
@@ -105,7 +96,6 @@ export const getUpgrades = (equipedItemIds: EquipedItem[]): Upgrade[] => {
numHero >= itemsForSlot ? [] : [UpgradeType.Hero], numHero >= itemsForSlot ? [] : [UpgradeType.Hero],
numMyth >= itemsForSlot ? [] : [UpgradeType.Myth], numMyth >= itemsForSlot ? [] : [UpgradeType.Myth],
].flat(); ].flat();
console.log(slot, numChampion, numHero, numMyth, upgradeTypes);
return items.map((item) => { return items.map((item) => {
const equiped = equipedInSlot.find((i) => i.item.id === item.id); const equiped = equipedInSlot.find((i) => i.item.id === item.id);
if (equiped) { if (equiped) {
@@ -128,12 +118,27 @@ export const hasUpgradeType =
(upgrade: Upgrade): boolean => (upgrade: Upgrade): boolean =>
upgrade.upgradeTypes.includes(type); upgrade.upgradeTypes.includes(type);
export const itemsPerSlot = (slot: Slot): number => { export const itemsPerSlot = (
slot: Slot,
weaponConfig: WeaponConfig,
): number => {
switch (slot) { switch (slot) {
case "trinket": case "trinket":
case "finger": case "finger":
case "1h-weapon":
return 2; return 2;
case "1h-weapon":
switch (weaponConfig) {
case WeaponConfig.DualWield:
return 2;
case WeaponConfig.TwoHander:
return 0;
default:
return 1;
}
case "off-hand":
return weaponConfig === WeaponConfig.WeaponAndOffHand ? 1 : 0;
case "2h-weapon":
return weaponConfig === WeaponConfig.TwoHander ? 1 : 0;
default: default:
return 1; return 1;
} }

View File

@@ -1,13 +1,16 @@
import type { EquipedItem, Item, ItemId, Quality } from "./types"; import type { EquipedItem, Item, ItemId, Quality } from "./types";
import { WeaponConfig } from "./types";
export type State = { export type State = {
equipedItems: EquipedItem[]; equipedItems: EquipedItem[];
bisList: ItemId[]; bisList: ItemId[];
weaponConfig: WeaponConfig;
}; };
export const emptyState: State = { export const emptyState: State = {
equipedItems: [], equipedItems: [],
bisList: [], bisList: [],
weaponConfig: WeaponConfig.TwoHander, // Default weapon config
}; };
export type Action = export type Action =
@@ -23,6 +26,10 @@ export type Action =
action: "changeQuality"; action: "changeQuality";
itemId: ItemId; itemId: ItemId;
quality: Quality; quality: Quality;
}
| {
action: "changeWeaponConfig";
weaponConfig: WeaponConfig;
}; };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
@@ -58,6 +65,11 @@ export const reducer = (state: State, action: Action): State => {
return item; return item;
}), }),
}; };
case "changeWeaponConfig":
return {
...state,
weaponConfig: action.weaponConfig,
};
} }
}; };

View File

@@ -13,7 +13,7 @@ export const Slots = [
"trinket", "trinket",
"1h-weapon", "1h-weapon",
"2h-weapon", "2h-weapon",
"shield", "off-hand",
] as const; ] as const;
export type Slot = (typeof Slots)[number]; export type Slot = (typeof Slots)[number];
@@ -39,13 +39,15 @@ export type Source =
| "halls-of-atonement" | "halls-of-atonement"
| "priory" | "priory"
| "streets" | "streets"
| "manaforge omega"; | "manaforge omega"
| "crafted";
export type Item = { export type Item = {
id: ItemId; id: ItemId;
slot: Slot; slot: Slot;
name: string; name: string;
source: Source; source: Source;
icon?: string; // Wowhead icon name (not ID)
}; };
export type EquipedItem = { export type EquipedItem = {
@@ -64,3 +66,11 @@ export const UpgradeType = {
export type UpgradeType = (typeof UpgradeType)[keyof typeof UpgradeType]; export type UpgradeType = (typeof UpgradeType)[keyof typeof UpgradeType];
export type Upgrade = { item: Item; upgradeTypes: UpgradeType[] }; export type Upgrade = { item: Item; upgradeTypes: UpgradeType[] };
export const WeaponConfig = {
WeaponAndOffHand: "weapon and off-hand",
DualWield: "dual wield",
TwoHander: "two-hander",
};
export type WeaponConfig = (typeof WeaponConfig)[keyof typeof WeaponConfig];