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 { StateContext } from "./lib/context/StateContext";
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() {
const [state, dispatch] = useReducer(withLoadingAction(reducer), "loading");
@@ -25,6 +26,7 @@ function App() {
state: {
equipedItems: [{ id: 219309 as ItemId, quality: "champion" }],
bisList: [],
weaponConfig: WeaponConfig.TwoHander,
},
});
}
@@ -46,11 +48,15 @@ function App() {
}}
>
<h1>WoW Gear Finder</h1>
<h2>Config</h2>
<WeaponConfigPicker />
<h2>Equipment</h2>
<Equipment
state={state}
onEquip={(item) => dispatch({ action: "equipItem", item })}
onUnequip={(item) => dispatch({ action: "unequipItem", item })}
/>
<h2>Upgrades</h2>
<SourceList state={state} />
</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 { Slots, type Item, type Slot } from "../lib/types";
import { ItemLink } from "./ItemLink";
@@ -12,16 +12,28 @@ export type Props = {
onUnequip: (item: Item) => void;
};
export const Equipment = ({ state, onEquip, onUnequip }: Props) => (
export const Equipment = ({ state, onEquip, onUnequip }: Props) => {
return (
<div>
<ItemTypeahead value={null} onSelect={onEquip} />
<ItemTypeahead value={null} onSelect={(item) => item && onEquip(item)} />
<div className={styles.equipedItems}>
{Slots.map((slot) => (
<Slot state={state} slot={slot} onUnequip={onUnequip} key={slot} />
))}
{Slots.map((slot) => {
if (itemsPerSlot(slot, state.weaponConfig) > 0) {
return (
<Slot
state={state}
slot={slot}
onUnequip={onUnequip}
key={slot}
/>
);
}
return null;
})}
</div>
</div>
);
};
type SlotProps = {
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 * as styles from "./ItemLink.module.css";
export type Props = {
item: Item;
@@ -9,7 +10,13 @@ export const ItemLink = ({ item }: Props) => (
href={`https://www.wowhead.com/item=${item.id}`}
target="_blank"
rel="noopener noreferrer"
className={styles.itemLink}
>
{item.icon && (
<img
src={`https://wow.zamimg.com/images/wow/icons/medium/${item.icon}.jpg`}
/>
)}
{item.name}
</a>
);

View File

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

View File

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

View File

@@ -3,3 +3,7 @@
gap: 8px;
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 type { State } from "../lib/state";
import {
@@ -7,6 +8,7 @@ import {
type Source,
type Upgrade,
} from "../lib/types";
import { ItemLink } from "./ItemLink";
import styles from "./SourceList.module.css";
export type Props = {
@@ -14,12 +16,12 @@ export type 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,
(upgrade: { item: Item }) => upgrade.item.source,
);
) as Record<Source, Upgrade[]>;
return (
<div className={styles.sourceList}>
@@ -28,22 +30,74 @@ export const SourceList = ({ state }: Props) => {
<div>Hero</div>
<div>Myth</div>
{Object.entries(upgradesBySource).map(
([source, items]: [string, Upgrade[]]) => {
const champion = items.filter(
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>
</>
);
},
([source, items]: [string, Upgrade[]]) => (
<SourceInfo source={source as Source} items={items} key={source} />
),
)}
</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",
name: "Incorporeal Warpclaw",
source: "aldani" as const,
icon: "inv_misc_nightsaberclaw_mana",
},
{
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",
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[];

View File

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

View File

@@ -1,8 +1,23 @@
export { Aldani } from "./aldani";
export { AraKara } from "./ara-kara";
export { Dawnbreaker } from "./dawnbreaker";
export { Floodgate } from "./floodgate";
export { HallsOfAtonement } from "./halls-of-atonement";
export { PrioryOfTheSacredFlame } from "./priory";
export { Streets, Gambit } from "./tazavesh";
export { ManaforgeOmega } from "./manaforge-omega";
import type { Item } from "../types";
import { Aldani } from "./aldani";
import { AraKara } from "./ara-kara";
import { Crafted } from "./crafted";
import { Dawnbreaker } from "./dawnbreaker";
import { Floodgate } from "./floodgate";
import { HallsOfAtonement } from "./halls-of-atonement";
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",
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;

View File

@@ -1,6 +1,7 @@
import _ from "lodash";
import {
UpgradeType,
WeaponConfig,
type EquipedItem,
type Item,
type ItemId,
@@ -8,29 +9,7 @@ import {
type Slot,
type Upgrade,
} from "./types";
import {
Aldani,
AraKara,
Dawnbreaker,
Floodgate,
HallsOfAtonement,
PrioryOfTheSacredFlame,
Streets,
Gambit,
ManaforgeOmega,
} from "./drops";
export const AllItems: Item[] = [
...PrioryOfTheSacredFlame,
...Aldani,
...AraKara,
...Dawnbreaker,
...Floodgate,
...HallsOfAtonement,
...Streets,
...Gambit,
...ManaforgeOmega,
];
import { AllItems } from "./drops";
export const ItemsById: Record<string, Item> = AllItems.reduce(
(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]) => {
const equipedItems = equipedItemIds.flatMap(({ id, quality }) => ({
item: ItemsById[id],
@@ -96,7 +78,16 @@ export const getUpgrades = (equipedItemIds: EquipedItem[]): Upgrade[] => {
const equipedInSlot = equipedItems.filter(
(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 numHero = equipedInSlot.filter(qualityAtLeast("hero")).length;
const numMyth = equipedInSlot.filter(qualityAtLeast("myth")).length;
@@ -105,7 +96,6 @@ export const getUpgrades = (equipedItemIds: EquipedItem[]): Upgrade[] => {
numHero >= itemsForSlot ? [] : [UpgradeType.Hero],
numMyth >= itemsForSlot ? [] : [UpgradeType.Myth],
].flat();
console.log(slot, numChampion, numHero, numMyth, upgradeTypes);
return items.map((item) => {
const equiped = equipedInSlot.find((i) => i.item.id === item.id);
if (equiped) {
@@ -128,12 +118,27 @@ export const hasUpgradeType =
(upgrade: Upgrade): boolean =>
upgrade.upgradeTypes.includes(type);
export const itemsPerSlot = (slot: Slot): number => {
export const itemsPerSlot = (
slot: Slot,
weaponConfig: WeaponConfig,
): number => {
switch (slot) {
case "trinket":
case "finger":
case "1h-weapon":
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:
return 1;
}

View File

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

View File

@@ -13,7 +13,7 @@ export const Slots = [
"trinket",
"1h-weapon",
"2h-weapon",
"shield",
"off-hand",
] as const;
export type Slot = (typeof Slots)[number];
@@ -39,13 +39,15 @@ export type Source =
| "halls-of-atonement"
| "priory"
| "streets"
| "manaforge omega";
| "manaforge omega"
| "crafted";
export type Item = {
id: ItemId;
slot: Slot;
name: string;
source: Source;
icon?: string; // Wowhead icon name (not ID)
};
export type EquipedItem = {
@@ -64,3 +66,11 @@ export const UpgradeType = {
export type UpgradeType = (typeof UpgradeType)[keyof typeof 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];