import { naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType } from "./abilityCatalog"; import { normalizeActorResourcePool, type CombatActorState, } from "./combatActors"; import type { PartyMember } from "./partyStore"; import { isRomClassId } from "../app/characterCatalog"; import { deriveCharacterStats } from "./wotlkStats"; export function companionResourceMaximum(member: PartyMember, id: string): number { if (id === member.resourceType) return member.maxResource; const mirroredMaximum = member.resourceMaximums?.[id as ResourceType]; if (mirroredMaximum !== undefined) return mirroredMaximum; return id === "mana" && (member.statRuleset === "rom" || !isRomClassId(member.classId)) ? deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ).mana : id === "nature-power" ? naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level) : id === "psi" ? 6 : 100; } /** * Creates the authoritative companion actor. Existing actor values win over * the party-frame compatibility record once the actor has entered combat. */ export function companionActorFromMember( member: PartyMember, absorb = 0, existing?: CombatActorState | null, ): CombatActorState { const profile = resourceProfileForClass(member.classId); let resources: CombatActorState["resources"]; if (existing?.kind === "companion" && member.statRuleset === "rom" && isRomClassId(member.classId)) { const previousResources = existing.resources; const nextResources: Record> = {}; const resourceIds = new Set([...Object.keys(previousResources), ...Object.keys(member.resourcePools)]); for (const id of resourceIds) { const previous = previousResources[id]; const nextMaximum = companionResourceMaximum(member, id); const hasChangedMaximum = previous !== undefined && Math.abs(previous.maximum - nextMaximum) > 1e-6; const current = hasChangedMaximum ? member.resourcePools[id as ResourceType] ?? previous.current : previous?.current ?? member.resourcePools[id as ResourceType] ?? 0; nextResources[id] = normalizeActorResourcePool( id, current, nextMaximum, id === member.resourceType ? profile.regenerationPerSecond : previous?.regenerationPerSecond ?? 0, member.lastResourceSpentAt[id as ResourceType] ?? previous?.lastSpentAt ?? 0, ); } resources = nextResources; } else if (existing?.kind === "companion") { resources = existing.resources; } else { resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id, normalizeActorResourcePool( id, current ?? 0, companionResourceMaximum(member, id), id === member.resourceType ? profile.regenerationPerSecond : 0, member.lastResourceSpentAt[id as ResourceType] ?? 0, ), ])); } const actor = existing?.kind === "companion" ? existing : null; return Object.freeze({ id: member.id, threatActorId: member.id, kind: "companion" as const, classId: member.classId, statRuleset: member.statRuleset, level: member.level, health: actor?.health ?? member.health, maxHealth: actor?.maxHealth ?? member.maxHealth, absorb, resources, cooldowns: actor?.cooldowns ?? member.cooldowns, globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt, cast: actor?.cast ?? member.activeCast, controlledUntil: actor?.controlledUntil ?? member.controlledUntil, schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil, comboPoints: actor?.comboPoints ?? member.comboPoints, comboTargetId: actor?.comboTargetId ?? member.comboTargetId, runes: actor?.runes ?? member.runes, abilityIds: actor?.abilityIds, alive: (actor?.health ?? member.health) > 0, }); } /** Party and scene consumers receive a read-compatible mirror of actor state. */ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember { if (!actor || actor.kind !== "companion") return member; const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial>; const resourceMaximums = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.maximum])) as Partial>; const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial>; const primary = actor.resources[member.resourceType]; return { ...member, health: actor.health, maxHealth: actor.maxHealth, resource: primary?.current ?? member.resource, maxResource: primary?.maximum ?? member.maxResource, resourcePools, resourceMaximums, lastResourceSpentAt, comboPoints: actor.comboPoints, comboTargetId: actor.comboTargetId, cooldowns: actor.cooldowns, globalCooldownEndsAt: actor.globalCooldownEndsAt, activeCast: actor.cast, activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId, schoolLockedUntil: actor.schoolLockedUntil, runes: actor.runes, controlledUntil: actor.controlledUntil, status: actor.health <= 0 ? "down" : member.status, }; } export function companionActorWithMemberRuntime( actor: CombatActorState, member: PartyMember, ): CombatActorState { const profile = resourceProfileForClass(member.classId); const resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id, normalizeActorResourcePool( id, current ?? 0, companionResourceMaximum(member, id), id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0, member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0, ), ])); return Object.freeze({ ...actor, health: member.health, maxHealth: member.maxHealth, resources, cooldowns: member.cooldowns, globalCooldownEndsAt: member.globalCooldownEndsAt, cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null, controlledUntil: member.controlledUntil, schoolLockedUntil: member.schoolLockedUntil, comboPoints: member.comboPoints, comboTargetId: member.comboTargetId, runes: member.runes, alive: member.health > 0, }); }