227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
import type { BossGroupId } from "../bossCatalog";
|
|
import type { HealerClassId, MemberId, RunBuffId } from "../types";
|
|
import { groupDrop, type DifficultySlug, type MaterialStack } from "./loot";
|
|
|
|
export type GearOwnerId = HealerClassId | Exclude<MemberId, "aelia">;
|
|
export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet";
|
|
export type GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
|
export type GearStatId = "healingPower" | "damage" | "attackCooldown" | "maxHealth" | "moveSpeed" | "stunResist" | "hazardDamageTaken";
|
|
|
|
export interface GearSlotProgress {
|
|
level: GearLevel;
|
|
}
|
|
|
|
export interface ClassGearProgress {
|
|
slots: Record<GearSlotId, GearSlotProgress>;
|
|
infusionAbilityId: string | null;
|
|
passiveInfusionId: RunBuffId | null;
|
|
}
|
|
|
|
export type GearProgress = Record<GearOwnerId, ClassGearProgress>;
|
|
|
|
export interface GearRecipe {
|
|
ownerId: GearOwnerId;
|
|
slotId: GearSlotId;
|
|
statId: GearStatId;
|
|
primaryGroupId: BossGroupId;
|
|
secondaryGroupId: BossGroupId;
|
|
}
|
|
|
|
export interface GearUpgradeCost {
|
|
itemId: string;
|
|
itemName: string;
|
|
quantity: number;
|
|
}
|
|
|
|
export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = ["priest", "druid", "shaman", "brann", "nia", "orin", "vale"];
|
|
export const GEAR_SLOT_ORDER: readonly GearSlotId[] = ["weapon", "helmet", "chest", "legs", "feet"];
|
|
export const MAX_GEAR_LEVEL: GearLevel = 10;
|
|
|
|
export const GEAR_OWNER_LABELS: Record<GearOwnerId, string> = {
|
|
priest: "Priest",
|
|
druid: "Druid",
|
|
shaman: "Shaman",
|
|
brann: "Brann · Knight",
|
|
nia: "Nia · Ranger",
|
|
orin: "Orin · Mage",
|
|
vale: "Vale · Rogue",
|
|
};
|
|
|
|
export const GEAR_SLOT_LABELS: Record<GearSlotId, string> = {
|
|
weapon: "Weapon",
|
|
helmet: "Helmet",
|
|
chest: "Chest",
|
|
legs: "Legs",
|
|
feet: "Feet",
|
|
};
|
|
|
|
export const GEAR_STAT_LABELS: Record<GearStatId, string> = {
|
|
healingPower: "Healing Power",
|
|
damage: "Damage",
|
|
attackCooldown: "Cooldown",
|
|
maxHealth: "Max Health",
|
|
moveSpeed: "Move Speed",
|
|
stunResist: "Stun Resist",
|
|
hazardDamageTaken: "Hazard Damage Taken",
|
|
};
|
|
|
|
type RecipeSeed = Record<GearSlotId, readonly [BossGroupId, BossGroupId]>;
|
|
|
|
const HEALER_RECIPES: RecipeSeed = {
|
|
weapon: ["bind-venom", "breath-skyfall"],
|
|
helmet: ["ricochet", "burrow-eruption"],
|
|
chest: ["charge", "shockwave-fall"],
|
|
legs: ["slash-cross", "ricochet"],
|
|
feet: ["bind-venom", "burrow-eruption"],
|
|
};
|
|
|
|
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
|
|
priest: HEALER_RECIPES,
|
|
druid: HEALER_RECIPES,
|
|
shaman: HEALER_RECIPES,
|
|
brann: {
|
|
weapon: ["charge", "scuttle-burst"],
|
|
helmet: ["slash-cross", "burrow-eruption"],
|
|
chest: ["charge", "breath-skyfall"],
|
|
legs: ["charge", "ricochet"],
|
|
feet: ["burrow-eruption", "bind-venom"],
|
|
},
|
|
nia: {
|
|
weapon: ["ricochet", "breath-skyfall"],
|
|
helmet: ["bind-venom", "burrow-eruption"],
|
|
chest: ["charge", "rift-lanes"],
|
|
legs: ["ricochet", "slash-cross"],
|
|
feet: ["burrow-eruption", "bind-venom"],
|
|
},
|
|
orin: {
|
|
weapon: ["breath-skyfall", "bind-venom"],
|
|
helmet: ["burrow-eruption", "ricochet"],
|
|
chest: ["bind-venom", "charge"],
|
|
legs: ["ricochet", "slash-cross"],
|
|
feet: ["burrow-eruption", "charge"],
|
|
},
|
|
vale: {
|
|
weapon: ["slash-cross", "breath-skyfall"],
|
|
helmet: ["ricochet", "burrow-eruption"],
|
|
chest: ["charge", "rift-lanes"],
|
|
legs: ["slash-cross", "ricochet"],
|
|
feet: ["bind-venom", "burrow-eruption"],
|
|
},
|
|
};
|
|
|
|
function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId {
|
|
if (slotId === "weapon") return ownerId === "priest" || ownerId === "druid" || ownerId === "shaman" ? "healingPower" : "damage";
|
|
if (slotId === "helmet") return ownerId === "brann" ? "stunResist" : "attackCooldown";
|
|
if (slotId === "chest") return "maxHealth";
|
|
if (slotId === "legs") return "moveSpeed";
|
|
return "hazardDamageTaken";
|
|
}
|
|
|
|
export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
|
|
ownerId,
|
|
Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => {
|
|
const [primaryGroupId, secondaryGroupId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
|
|
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryGroupId, secondaryGroupId }];
|
|
})),
|
|
])) as Record<GearOwnerId, Record<GearSlotId, GearRecipe>>;
|
|
|
|
export function createDefaultGearProgress(): GearProgress {
|
|
return Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [
|
|
ownerId,
|
|
{
|
|
slots: Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => [slotId, { level: 0 }])),
|
|
infusionAbilityId: null,
|
|
passiveInfusionId: null,
|
|
},
|
|
])) as GearProgress;
|
|
}
|
|
|
|
export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, currentLevel: GearLevel): GearUpgradeCost[] {
|
|
if (currentLevel >= MAX_GEAR_LEVEL) return [];
|
|
const nextLevel = currentLevel + 1 as Exclude<GearLevel, 0>;
|
|
const recipe = GEAR_RECIPES[ownerId][slotId];
|
|
const difficultySlug = upgradeDifficultySlug(nextLevel);
|
|
const primary = groupDrop(recipe.primaryGroupId, difficultySlug);
|
|
const secondary = groupDrop(recipe.secondaryGroupId, difficultySlug);
|
|
if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }];
|
|
const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel;
|
|
const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1;
|
|
return [
|
|
{ itemId: primary.id, itemName: primary.name, quantity: primaryQuantity },
|
|
{ itemId: secondary.id, itemName: secondary.name, quantity: secondaryQuantity },
|
|
];
|
|
}
|
|
|
|
export function canAffordGearUpgrade(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): boolean {
|
|
return costs.every((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) >= cost.quantity);
|
|
}
|
|
|
|
export function canUpgradeGearSlot(
|
|
progress: GearProgress,
|
|
inventory: readonly MaterialStack[],
|
|
ownerId: GearOwnerId,
|
|
slotId: GearSlotId,
|
|
): boolean {
|
|
const currentLevel = progress[ownerId].slots[slotId].level;
|
|
return currentLevel < MAX_GEAR_LEVEL
|
|
&& canAffordGearUpgrade(inventory, gearUpgradeCosts(ownerId, slotId, currentLevel));
|
|
}
|
|
|
|
export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] {
|
|
if (!canAffordGearUpgrade(inventory, costs)) {
|
|
const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity);
|
|
throw new Error(missing ? `Need ${missing.quantity} ${missing.itemName}.` : "Missing gear materials.");
|
|
}
|
|
return inventory.flatMap((item) => {
|
|
const cost = costs.find((entry) => entry.itemId === item.id);
|
|
if (!cost) return [{ ...item }];
|
|
const quantity = item.quantity - cost.quantity;
|
|
return quantity > 0 ? [{ ...item, quantity }] : [];
|
|
});
|
|
}
|
|
|
|
export function upgradeGearSlot(
|
|
progress: GearProgress,
|
|
inventory: readonly MaterialStack[],
|
|
ownerId: GearOwnerId,
|
|
slotId: GearSlotId,
|
|
): { gearProgress: GearProgress; inventory: MaterialStack[] } {
|
|
const currentLevel = progress[ownerId].slots[slotId].level;
|
|
if (currentLevel >= MAX_GEAR_LEVEL) throw new Error(`${GEAR_SLOT_LABELS[slotId]} already at +${MAX_GEAR_LEVEL}.`);
|
|
const costs = gearUpgradeCosts(ownerId, slotId, currentLevel);
|
|
const inventoryAfter = spendGearCosts(inventory, costs);
|
|
return {
|
|
inventory: inventoryAfter,
|
|
gearProgress: {
|
|
...progress,
|
|
[ownerId]: {
|
|
...progress[ownerId],
|
|
slots: {
|
|
...progress[ownerId].slots,
|
|
[slotId]: { level: currentLevel + 1 as GearLevel },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function gearBonusText(statId: GearStatId, level: number): string {
|
|
if (statId === "moveSpeed") return `+${formatPercent(level * 2.5)}%`;
|
|
if (statId === "attackCooldown" || statId === "hazardDamageTaken") return `-${level * 3}%`;
|
|
if (statId === "stunResist") return `-${level * 8}%`;
|
|
return `+${level * 4}%`;
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
}
|
|
|
|
// IWT2 parity: +6 through +10 return to Veteran drops while infusion costs use higher tiers.
|
|
function upgradeDifficultySlug(level: Exclude<GearLevel, 0>): DifficultySlug {
|
|
if (level <= 2) return "initiate";
|
|
if (level >= 6) return "veteran";
|
|
if (level === 3) return "veteran";
|
|
if (level === 4) return "champion";
|
|
return "mythic";
|
|
}
|