Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+214
View File
@@ -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";
}