forked from phenom/healer-man
328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import {
|
|
baseClassFor,
|
|
classById,
|
|
contentCategoryForClass,
|
|
type BaseClassId,
|
|
type ClassId,
|
|
} from "../app/characterCatalog";
|
|
import type { ContentCategoryId } from "../app/contentCategories";
|
|
import {
|
|
EMPTY_ITEM_STATS,
|
|
ITEM_STAT_KEYS,
|
|
type ItemStats,
|
|
type NumericItemStatKey,
|
|
type WeaponStats,
|
|
} from "./itemStats";
|
|
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
|
|
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
|
|
|
|
export const PLAYER_EQUIPMENT_OWNER_ID = "player";
|
|
|
|
export const EQUIPMENT_SLOTS = [
|
|
"head",
|
|
"neck",
|
|
"shoulder",
|
|
"back",
|
|
"chest",
|
|
"wrist",
|
|
"hands",
|
|
"waist",
|
|
"legs",
|
|
"feet",
|
|
"finger",
|
|
"trinket",
|
|
"main-hand",
|
|
"off-hand",
|
|
"ranged",
|
|
] as const;
|
|
|
|
export type EquipmentSlot = (typeof EQUIPMENT_SLOTS)[number];
|
|
export type EquipmentLoadout = Partial<Record<EquipmentSlot, string>>;
|
|
export type EquipmentAssignments = Readonly<Record<string, EquipmentLoadout>>;
|
|
|
|
export interface EquipmentChange {
|
|
readonly ok: boolean;
|
|
readonly assignments: EquipmentAssignments;
|
|
readonly slot?: EquipmentSlot;
|
|
readonly reason?: string;
|
|
}
|
|
|
|
export interface EquipmentClassContext {
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly level?: number;
|
|
/** Saved owner category; falls back to the owner's class category when absent. */
|
|
readonly ownerCategory?: ContentCategoryId;
|
|
}
|
|
|
|
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
|
|
|
|
const ARMOR_RANK: Readonly<Record<string, number>> = Object.freeze({
|
|
cloth: 1,
|
|
leather: 2,
|
|
mail: 3,
|
|
chain: 3,
|
|
plate: 4,
|
|
});
|
|
|
|
const SHIELD_CLASSES = new Set<ClassId>([
|
|
"warrior", "paladin", "shaman",
|
|
"knight-of-xoroth", "guardian", "cultist", "starcaller", "sun-cleric",
|
|
"rom-warrior", "rom-knight", "rom-priest", "rom-warden", "rom-champion",
|
|
]);
|
|
|
|
const WEAPON_CLASSES: Readonly<Record<string, ReadonlySet<BaseClassId>>> = Object.freeze({
|
|
bow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
|
|
crossbow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
|
|
dagger: new Set<BaseClassId>(["warrior", "hunter", "rogue", "priest", "shaman", "mage", "warlock", "druid"]),
|
|
mace: new Set<BaseClassId>(["warrior", "paladin", "priest", "death-knight", "shaman", "druid"]),
|
|
staff: new Set<BaseClassId>(["warrior", "hunter", "priest", "shaman", "mage", "warlock", "druid"]),
|
|
wand: new Set<BaseClassId>(["priest", "mage", "warlock"]),
|
|
});
|
|
|
|
export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null {
|
|
const slot = item.inventorySlot === "one-hand" || item.inventorySlot === "two-hand"
|
|
? "main-hand"
|
|
: item.inventorySlot;
|
|
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
|
|
}
|
|
|
|
/**
|
|
* Internal shape-only predicate for synthetic catalog scoring. Callers must
|
|
* still use classCanEquipItem before assigning or applying an item.
|
|
*/
|
|
export function classItemShapeFitsEquipmentRules(
|
|
classId: ClassId,
|
|
item: InventoryItem,
|
|
context: EquipmentClassContext = {},
|
|
): boolean {
|
|
const slot = equipmentSlotForItem(item);
|
|
if (!slot) return false;
|
|
const level = context.level ?? Number.POSITIVE_INFINITY;
|
|
if (classId.startsWith("rom-")) {
|
|
if (slot === "off-hand" && item.weaponType
|
|
&& level < 16) return false;
|
|
if (slot === "off-hand" && item.weaponType
|
|
&& classId !== "rom-rogue" && context.secondaryClassId !== "rom-rogue") return false;
|
|
if (slot === "ranged") {
|
|
if (item.weaponType === "bow" || item.weaponType === "crossbow") return classId === "rom-scout"
|
|
|| (context.secondaryClassId === "rom-scout" && level >= 10);
|
|
if (item.weaponType === "projectile" || item.weaponType === "throwing") return (classId === "rom-rogue"
|
|
|| context.secondaryClassId === "rom-rogue") && level >= 12;
|
|
return false;
|
|
}
|
|
}
|
|
if (item.armorType === "shield" && !SHIELD_CLASSES.has(classId)) return false;
|
|
if (item.armorType && item.armorType !== "shield") {
|
|
const requiredRank = ARMOR_RANK[item.armorType] ?? Number.POSITIVE_INFINITY;
|
|
if (requiredRank > ARMOR_RANK[classById(classId).armor]) return false;
|
|
}
|
|
if (item.weaponType) {
|
|
const allowedClasses = WEAPON_CLASSES[item.weaponType];
|
|
if (!allowedClasses?.has(baseClassFor(classId))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
|
|
const classCategory = contentCategoryForClass(classId);
|
|
if (context.ownerCategory !== undefined && context.ownerCategory !== classCategory) return false;
|
|
return inventoryItemUsableForCategory(item, classCategory)
|
|
&& classItemShapeFitsEquipmentRules(classId, item, context);
|
|
}
|
|
|
|
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
|
|
return Object.fromEntries(
|
|
Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]),
|
|
);
|
|
}
|
|
|
|
function removeItemAssignment(
|
|
assignments: Record<string, EquipmentLoadout>,
|
|
instanceId: string,
|
|
): void {
|
|
for (const [ownerId, loadout] of Object.entries(assignments)) {
|
|
const next = { ...loadout };
|
|
for (const slot of EQUIPMENT_SLOTS) {
|
|
if (next[slot] === instanceId) delete next[slot];
|
|
}
|
|
if (Object.keys(next).length) assignments[ownerId] = next;
|
|
else delete assignments[ownerId];
|
|
}
|
|
}
|
|
|
|
export function equipInventoryItem(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
ownerId: string,
|
|
ownerClassId: ClassId,
|
|
instanceId: string,
|
|
classContext: EquipmentClassContext = {},
|
|
): EquipmentChange {
|
|
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
|
|
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
|
|
const ownerCategory = contentCategoryForClass(ownerClassId);
|
|
if ((classContext.ownerCategory !== undefined && classContext.ownerCategory !== ownerCategory)
|
|
|| !inventoryItemUsableForCategory(item, ownerCategory)) {
|
|
const reason = ownerCategory === "rom" && item.contentCategory === "rom"
|
|
? "That item has no validated RuneWaker-native gear values."
|
|
: "That item belongs to a different game category.";
|
|
return { ok: false, assignments, reason };
|
|
}
|
|
const slot = equipmentSlotForItem(item);
|
|
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
|
|
if (!classCanEquipItem(ownerClassId, item, classContext)) {
|
|
return { ok: false, assignments, reason: `That class cannot equip ${item.name}.` };
|
|
}
|
|
|
|
const next = mutableAssignments(assignments);
|
|
removeItemAssignment(next, instanceId);
|
|
const loadout = { ...(next[ownerId] ?? {}) };
|
|
|
|
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete loadout["off-hand"];
|
|
if (slot === "off-hand" && loadout["main-hand"]) {
|
|
const mainHand = inventory.find((candidate) => candidate.instanceId === loadout["main-hand"]);
|
|
if (mainHand?.inventorySlot === "two-hand") delete loadout["main-hand"];
|
|
}
|
|
|
|
loadout[slot] = instanceId;
|
|
next[ownerId] = loadout;
|
|
return { ok: true, assignments: next, slot };
|
|
}
|
|
|
|
export function unequipInventorySlot(
|
|
assignments: EquipmentAssignments,
|
|
ownerId: string,
|
|
slot: EquipmentSlot,
|
|
): EquipmentAssignments {
|
|
const loadout = assignments[ownerId];
|
|
if (!loadout?.[slot]) return assignments;
|
|
const next = mutableAssignments(assignments);
|
|
const nextLoadout = { ...next[ownerId] };
|
|
delete nextLoadout[slot];
|
|
if (Object.keys(nextLoadout).length) next[ownerId] = nextLoadout;
|
|
else delete next[ownerId];
|
|
return next;
|
|
}
|
|
|
|
export function equippedOwnerId(
|
|
assignments: EquipmentAssignments,
|
|
instanceId: string,
|
|
): string | null {
|
|
for (const [ownerId, loadout] of Object.entries(assignments)) {
|
|
if (Object.values(loadout).includes(instanceId)) return ownerId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Returns one owner's equipped inventory records in stable character-slot order. */
|
|
export function equipmentItemsForOwner(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
ownerId: string,
|
|
): InventoryItem[] {
|
|
const loadout = assignments[ownerId];
|
|
if (!loadout) return [];
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
return EQUIPMENT_SLOTS.flatMap((slot) => {
|
|
const instanceId = loadout[slot];
|
|
const item = instanceId ? itemById.get(instanceId) : null;
|
|
return item ? [item] : [];
|
|
});
|
|
}
|
|
|
|
export function normalizeEquipment(
|
|
value: unknown,
|
|
inventory: readonly InventoryItem[],
|
|
ownerCategory?: ContentCategoryId,
|
|
): EquipmentAssignments {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
const assigned = new Set<string>();
|
|
const normalized: Record<string, EquipmentLoadout> = {};
|
|
|
|
for (const [ownerId, rawLoadout] of Object.entries(value as Record<string, unknown>)) {
|
|
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
|
|
const loadout: EquipmentLoadout = {};
|
|
for (const [rawSlot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
|
|
if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue;
|
|
const item = itemById.get(rawInstanceId);
|
|
if (!item || equipmentSlotForItem(item) !== rawSlot) continue;
|
|
if (ownerCategory !== undefined && !inventoryItemUsableForCategory(item, ownerCategory)) continue;
|
|
loadout[rawSlot as EquipmentSlot] = rawInstanceId;
|
|
assigned.add(rawInstanceId);
|
|
}
|
|
const mainHand = loadout["main-hand"]
|
|
? itemById.get(loadout["main-hand"]!)
|
|
: null;
|
|
if (mainHand?.inventorySlot === "two-hand") delete loadout["off-hand"];
|
|
if (Object.keys(loadout).length) normalized[ownerId] = loadout;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function equipmentItemLevel(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
ownerId: string,
|
|
): number {
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
return Object.values(assignments[ownerId] ?? {}).reduce(
|
|
(total, instanceId) => total + (instanceId ? itemById.get(instanceId)?.itemLevel ?? 0 : 0),
|
|
0,
|
|
);
|
|
}
|
|
|
|
/** Adds the attributes from every item equipped by one character. */
|
|
export function equipmentStats(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
ownerId: string,
|
|
): ItemStats {
|
|
const loadout = assignments[ownerId];
|
|
if (!loadout) return EMPTY_ITEM_STATS;
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record<NumericItemStatKey, number>;
|
|
let meleeWeapon: WeaponStats | null = null;
|
|
let rangedWeapon: WeaponStats | null = null;
|
|
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
|
|
for (const instanceId of Object.values(loadout)) {
|
|
const stats = instanceId ? itemById.get(instanceId)?.stats : null;
|
|
if (!stats) continue;
|
|
for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key];
|
|
const contentCategory = itemById.get(instanceId)?.contentCategory;
|
|
const nativePayload = stats.romNative
|
|
?? (contentCategory === "rom" || contentCategory === "coa"
|
|
? undefined
|
|
: romNativeGearFromSharedDirectStats(stats));
|
|
if (nativePayload) nativePayloads.push(nativePayload);
|
|
if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon;
|
|
if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon;
|
|
}
|
|
const romNative = sumRomNativeGearStats(nativePayloads);
|
|
return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon, ...(romNative ? { romNative } : {}) });
|
|
}
|
|
|
|
/** Keeps incompatible items owned and visibly assigned while excluding their stats in the active class orientation. */
|
|
export function activeEquipmentStatsForClass(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
ownerId: string,
|
|
classId: ClassId,
|
|
ownerCategory?: ContentCategoryId,
|
|
): ItemStats {
|
|
const classCategory = contentCategoryForClass(classId);
|
|
if (ownerCategory !== undefined && ownerCategory !== classCategory) return EMPTY_ITEM_STATS;
|
|
const loadout = assignments[ownerId];
|
|
if (!loadout) return EMPTY_ITEM_STATS;
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
const activeAssignments: EquipmentAssignments = {
|
|
[ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => {
|
|
const item = instanceId ? itemById.get(instanceId) : null;
|
|
return Boolean(item
|
|
&& inventoryItemUsableForCategory(item, classCategory)
|
|
&& classCanEquipItem(classId, item));
|
|
})),
|
|
};
|
|
return equipmentStats(activeAssignments, inventory, ownerId);
|
|
}
|