Files
healer-man/src/game/equipment.ts
T

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