Files
i-want-to-heal-mmo/src/game/progression/gear.ts
T
2026-07-12 23:20:15 -04:00

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";
}