Release v0.1.3 2026-07-11
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import type { HealerClassId, MemberId, BossId, RunBuffId } from "../types";
|
||||
import { bossCoinDrop, 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;
|
||||
primaryBossId: BossId;
|
||||
secondaryBossId: BossId;
|
||||
}
|
||||
|
||||
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 [BossId, BossId]>;
|
||||
|
||||
const HEALER_RECIPES: RecipeSeed = {
|
||||
weapon: ["vexa", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "bulldrome"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
};
|
||||
|
||||
const OWNER_RECIPE_SEEDS: Record<GearOwnerId, RecipeSeed> = {
|
||||
priest: HEALER_RECIPES,
|
||||
druid: HEALER_RECIPES,
|
||||
shaman: HEALER_RECIPES,
|
||||
brann: {
|
||||
weapon: ["obsidian-ram-golem", "bulldrome"],
|
||||
helmet: ["ember-mantis-duelist", "sandglass-scorpion"],
|
||||
chest: ["obsidian-ram-golem", "cindermaw"],
|
||||
legs: ["bulldrome", "cinderback-ricochet"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
},
|
||||
nia: {
|
||||
weapon: ["cinderback-ricochet", "cindermaw"],
|
||||
helmet: ["vexa", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "vexa"],
|
||||
},
|
||||
orin: {
|
||||
weapon: ["cindermaw", "vexa"],
|
||||
helmet: ["sandglass-scorpion", "cinderback-ricochet"],
|
||||
chest: ["vexa", "obsidian-ram-golem"],
|
||||
legs: ["cinderback-ricochet", "ember-mantis-duelist"],
|
||||
feet: ["sandglass-scorpion", "bulldrome"],
|
||||
},
|
||||
vale: {
|
||||
weapon: ["ember-mantis-duelist", "cindermaw"],
|
||||
helmet: ["cinderback-ricochet", "sandglass-scorpion"],
|
||||
chest: ["bulldrome", "obsidian-ram-golem"],
|
||||
legs: ["ember-mantis-duelist", "cinderback-ricochet"],
|
||||
feet: ["vexa", "sandglass-scorpion"],
|
||||
},
|
||||
};
|
||||
|
||||
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 [primaryBossId, secondaryBossId] = OWNER_RECIPE_SEEDS[ownerId][slotId];
|
||||
return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryBossId, secondaryBossId }];
|
||||
})),
|
||||
])) 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 = bossCoinDrop(recipe.primaryBossId, difficultySlug);
|
||||
const secondary = bossCoinDrop(recipe.secondaryBossId, 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 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 coins 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";
|
||||
}
|
||||
Reference in New Issue
Block a user