import { create } from "zustand"; import { PLAYABLE_RACES, classesForRace, isRomClassId, randomAppearance, supportedGenders, type CharacterAppearance, type ClassId, type GenderId, type RaceId, } from "../app/characterCatalog"; import { type EquipmentAssignments, } from "./equipment"; import { resolvedEquipmentStats } from "./heirloomEquipment"; import { specializationsForClass } from "./specializationCatalog"; import { EMPTY_ITEM_STATS, healthFromStamina, type DamageSchool, type ItemStats, } from "./itemStats"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { damageAfterEquipmentMitigation, deriveCharacterStats, } from "./wotlkStats"; import { clampPlayerLevel } from "./progression"; import type { InventoryItem } from "./lootTypes"; import type { RomNativeRecoveryClock } from "./romNativeRecovery"; import { runtimeDefaultPartyTalentProfile, runtimePartyTalentRanksForLevel, } from "./partyTalentRuntime"; import { DEFAULT_COMPANION_SPEC_READINESS_POLICY, WOW_COMPANION_SPECIALIZATIONS, companionSpecializationsForPolicy, type CompanionSpecReadinessPolicy, } from "./companionSpecReadiness"; import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog"; import type { EnemyControlMechanic } from "./enemyAttacks"; import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset"; import type { ContentCategoryId } from "../app/contentCategories"; import { isValidRomClassPair, ROM_SECONDARY_UNLOCK_LEVEL } from "../app/romDualClass"; import type { PartyRole, PartySize } from "./partyRoles"; import { abilitiesForCharacter, learnedAbilityForBuild, naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType, } from "./abilityCatalog"; import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors"; import { ROM_FIVE_IDENTITIES, romFiveIdentity } from "./romFiveIdentities"; export { classCanFillPartyRole, defaultPartyRoleForClass, PARTY_ROLE_LABELS, partyRolesForClass, type PartyRole, type PartySize, } from "./partyRoles"; export type PartyCommand = "attack" | "defend" | "stop"; export type PartyMemberStatus = "ready" | "advancing" | "guarding" | "healing" | "attacking" | "holding" | "down"; type PartyActiveCast = CombatActorCastState & { readonly canCastWhileMoving?: boolean; /** Backdraft's already-hasted GCD duration, retained until cast completion. */ readonly backdraftGcdMs?: number; /** Tidal Waves charges spent at cast start, retained for Lesser Healing Wave's crit roll. */ readonly tidalWavesChargesConsumed?: number; /** Maelstrom Weapon rank and stacks spent at cast start, retained for its PvP damage rider. */ readonly maelstromWeaponRank?: number; readonly maelstromWeaponStacksConsumed?: number; /** Arcane Potency's next-spell critical chance, snapshotted when a Mage cast starts. */ readonly arcanePotencyCriticalChanceBonusPercent?: number; /** Mana committed for this cast, used by Master of Elements critical refunds. */ readonly manaSpent?: number; /** DBC-derived pre-discount base Mana cost retained for Paladin Illumination. */ readonly illuminationBaseManaCost?: number; /** Prevents later pulses of a channel cast from repeating Holy Candle's cast trigger. */ readonly romPriestHolyCandleCharged?: boolean; }; export interface PartyMember { readonly id: string; readonly name: string; readonly raceId: RaceId; readonly gender: GenderId; readonly appearance: CharacterAppearance; readonly classId: ClassId; readonly statRuleset?: CharacterStatRuleset; readonly romNativeRecoveryClock?: RomNativeRecoveryClock; readonly secondaryClassId?: ClassId | null; readonly specialization: string; readonly specializationId: string | null; readonly talentTreeId: string; readonly talentProfileId: string | null; readonly talentRanks: TalentRanks; /** Explicitly configured WoW glyphs; generated companions have none by default. */ readonly glyphIds?: readonly string[]; readonly role: PartyRole; readonly level: number; readonly health: number; readonly maxHealth: number; readonly resourceType: ResourceType; readonly resourceName: string; readonly resource: number; readonly maxResource: number; readonly resourcePools: Readonly>>; readonly resourceMaximums?: Readonly>>; readonly lastResourceSpentAt: Readonly>>; readonly warriorAngerManagementRageRemainderMs?: number; readonly comboPoints: number; readonly comboTargetId: string | null; readonly cooldowns: Readonly>; readonly globalCooldownEndsAt: number; readonly activeCast: PartyActiveCast | null; readonly activeCastTargetId: string | null; readonly schoolLockedUntil: Readonly>>; readonly runes: readonly DeathKnightRuneState[]; readonly gearStats: ItemStats; readonly status: PartyMemberStatus; readonly nextActionAt: number; readonly tauntReadyAt: number; readonly controlledUntil: number; readonly controlMechanic: EnemyControlMechanic | null; } interface PartyTemplate { readonly name: string; readonly classId: ClassId; readonly statRuleset?: CharacterStatRuleset; readonly secondaryClassId?: ClassId | null; readonly raceId?: RaceId; readonly gender?: GenderId; readonly specialization: string; readonly talentTreeId: string; readonly talentProfileId?: string | null; } export interface ConfiguredPartyMemberSeed { readonly name: string; readonly classId: ClassId; readonly statRuleset?: CharacterStatRuleset; readonly secondaryClassId?: ClassId | null; readonly raceId?: RaceId; readonly gender?: GenderId; readonly specialization: string; readonly specializationId?: string | null; readonly talentTreeId?: string; readonly talentProfileId?: string | null; readonly talentRanks?: TalentRanks; readonly glyphIds?: readonly string[]; readonly role: PartyRole; } export interface CompanionPartyGenerationOptions { readonly readinessPolicy?: CompanionSpecReadinessPolicy; readonly readinessPolicyRevision?: number; readonly includeBetaSpecs?: boolean; readonly excludedPlayerSpecializationId?: string | null; readonly excludedPlayerSpecializationIds?: readonly string[]; readonly allowedCompanionSpecializationIds?: readonly string[]; /** All real-player roles in a shared party, including the local player. */ readonly occupiedHumanRoles?: readonly PartyRole[]; /** The active RoM pair, used to exclude that native identity when the rest can fill the party. */ readonly playerRomClassPair?: { readonly primaryClassId: ClassId; readonly secondaryClassId: ClassId | null; }; } function companionTemplateFor(specializationId: string): PartyTemplate { const definition = specializationsForClass(specializationId.split(":", 1)[0] as ClassId) .find((candidate) => candidate.id === specializationId); if (!definition) throw new Error(`Unknown companion specialization ${specializationId}.`); const profile = runtimeDefaultPartyTalentProfile(definition.classId, definition.displayName); return Object.freeze({ name: profile.characterName, classId: definition.classId, specialization: definition.displayName, talentTreeId: `${definition.classId}-${definition.talentTree.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, talentProfileId: profile.id, }); } function wowTemplates( role: PartyRole, options: CompanionPartyGenerationOptions, seats: number, ): readonly PartyTemplate[] { const policy = options.readinessPolicy ?? DEFAULT_COMPANION_SPEC_READINESS_POLICY; const allowed = options.allowedCompanionSpecializationIds ? new Set(options.allowedCompanionSpecializationIds) : null; const definitions = allowed ? WOW_COMPANION_SPECIALIZATIONS.filter((definition) => definition.role === role && allowed.has(definition.id)) : companionSpecializationsForPolicy(policy, options.includeBetaSpecs === true, role); const excluded = new Set([ ...(options.excludedPlayerSpecializationIds ?? []), ...(options.excludedPlayerSpecializationId ? [options.excludedPlayerSpecializationId] : []), ]); const withoutPlayers = definitions.filter((definition) => !excluded.has(definition.id)); const candidates = withoutPlayers.length >= seats ? withoutPlayers : definitions; return candidates.map((definition) => companionTemplateFor(definition.id)); } const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([ { ...romFiveIdentity("rom-knight", "tank")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" }, ]); const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([ { ...romFiveIdentity("rom-priest", "healer")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" }, ]); const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([ { ...romFiveIdentity("rom-scout", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" }, { ...romFiveIdentity("rom-rogue", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" }, { ...romFiveIdentity("rom-mage", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" }, ]); export const TANK_SPECIALIZATIONS = Object.freeze(new Set( WOW_COMPANION_SPECIALIZATIONS.filter((definition) => definition.role === "tank") .map((definition) => `${definition.classId}:${definition.displayName}`), )); export function isTankSpecialization(classId: ClassId, specialization: string): boolean { return TANK_SPECIALIZATIONS.has(`${classId}:${specialization}`); } export const HEALER_SPECIALIZATIONS = Object.freeze(new Set( WOW_COMPANION_SPECIALIZATIONS.filter((definition) => definition.role === "healer") .map((definition) => `${definition.classId}:${definition.displayName}`), )); export function isHealerSpecialization(classId: ClassId, specialization: string): boolean { return HEALER_SPECIALIZATIONS.has(`${classId}:${specialization}`); } function hashSeed(seed: string): number { let hash = 2166136261; for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function nextRandom(state: { value: number }): number { let value = state.value || 0x9e3779b9; value ^= value << 13; value ^= value >>> 17; value ^= value << 5; state.value = value >>> 0; return state.value / 0x1_0000_0000; } function memberMaxHealth( level: number, role: PartyRole, gearStats: ItemStats = EMPTY_ITEM_STATS, classId: ClassId = "warrior", raceId: RaceId = "human", statRuleset: CharacterStatRuleset = "wow", secondaryClassId?: ClassId | null, gender: GenderId = "male", ): number { const safeLevel = clampPlayerLevel(level); if (statRuleset === "rom") { return deriveCharacterStats(classId, safeLevel, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).health; } const base = role === "tank" ? 185 + safeLevel * 32 : role === "healer" ? 115 + safeLevel * 22 : 105 + safeLevel * 21; return base + healthFromStamina(gearStats.stamina); } function memberMaxResource( classId: ClassId, level: number, raceId: RaceId, gearStats: ItemStats = EMPTY_ITEM_STATS, statRuleset: CharacterStatRuleset = "wow", secondaryClassId?: ClassId | null, gender: GenderId = "male", ): number { const safeLevel = clampPlayerLevel(level); const profile = resourceProfileForClass(classId); return profile.type === "mana" ? deriveCharacterStats(classId, safeLevel, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).mana : profile.maximum; } function secondaryResourceMaximum(type: ResourceType, member: PartyMember): number { if (type === "nature-power") { return naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level); } if (type === "psi") return 6; if (type === "mana" && member.statRuleset === "rom") { return deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom", ).mana; } return 100; } function initialSecondaryResource(type: ResourceType, maximum: number): number { return type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi" ? 0 : maximum; } function rescaledMemberValue( current: number, currentMaximum: number, nextMaximum: number, preserveNativeCap: boolean, minimum: number, ): number { if (current <= 0) return 0; const fraction = Math.max(0, Math.min(1, current / Math.max(1, currentMaximum))); if (preserveNativeCap && fraction >= 1) return nextMaximum; return Math.max(minimum, Math.min(nextMaximum, Math.round(fraction * nextMaximum))); } /** Keeps every resource used by the member's learned kit available to companion AI. */ function withMemberAbilityResourcePools(member: PartyMember): PartyMember { const types = new Set([member.resourceType]); if (member.statRuleset === "rom" && isRomClassId(member.classId) && deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom", ).romNative?.usesMana) { types.add("mana"); } for (const ability of abilitiesForCharacter(member.classId, member.secondaryClassId)) { const learned = learnedAbilityForBuild(ability, member.level, member.talentRanks); if (!learned) continue; for (const cost of learned.costs?.length ? learned.costs : [learned.cost]) { if (cost.resource !== "health") types.add(cost.resource); } } const resourcePools: Partial> = {}; const resourceMaximums: Partial> = {}; for (const type of types) { const maximum = type === member.resourceType ? member.maxResource : member.resourceMaximums?.[type] ?? secondaryResourceMaximum(type, member); resourceMaximums[type] = maximum; if (type === member.resourceType) { resourcePools[type] = member.resource; continue; } resourcePools[type] = Math.max(0, Math.min( maximum, member.resourcePools[type] ?? initialSecondaryResource(type, maximum), )); } return { ...member, resourcePools: Object.freeze(resourcePools), resourceMaximums: Object.freeze(resourceMaximums), }; } function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember { const maxHealth = memberMaxHealth( member.level, member.role, gearStats, member.classId, member.raceId, member.statRuleset, member.secondaryClassId, member.gender, ); const health = member.health <= 0 ? 0 : rescaledMemberValue(member.health, member.maxHealth, maxHealth, member.statRuleset === "rom", 1); const maxResource = memberMaxResource( member.classId, member.level, member.raceId, gearStats, member.statRuleset, member.secondaryClassId, member.gender, ); const resource = rescaledMemberValue( member.resource, member.maxResource, maxResource, member.statRuleset === "rom", 0, ); return withMemberAbilityResourcePools({ ...member, resourceMaximums: undefined, gearStats, health, maxHealth, resource, maxResource, resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), }); } function createMember( template: PartyTemplate, role: PartyRole, level: number, index: number, seed: string, configuredTalentRanks?: TalentRanks, ): PartyMember { const safeLevel = clampPlayerLevel(level); // Appearance has its own keyed stream so adding customization fields later // cannot reshuffle which class/spec templates make up this character's party. const identityRandom = { value: hashSeed(`${seed}:${role}:${index}:${template.classId}:${template.specialization}:appearance-v1`), }; const eligibleRaces = PLAYABLE_RACES.filter((race) => ( classesForRace(race.id).some((candidate) => candidate.id === template.classId) && (!template.secondaryClassId || isValidRomClassPair(race.id, template.classId, template.secondaryClassId)) )); if (template.secondaryClassId && !eligibleRaces.length) { throw new Error(`No playable race supports ${template.classId}/${template.secondaryClassId}.`); } if (template.raceId && !eligibleRaces.some((race) => race.id === template.raceId)) { throw new Error(`Race ${template.raceId} cannot play ${template.classId}/${template.secondaryClassId ?? "none"}.`); } const racePool = eligibleRaces.length ? eligibleRaces : PLAYABLE_RACES; const race = racePool.find((candidate) => candidate.id === template.raceId) ?? racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0]; const genders = supportedGenders(race.id); const gender: GenderId = template.gender ?? genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male"; const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom)); const id = `party-${role}-${index}-${template.classId}`; const statRuleset = normalizeCharacterStatRuleset( template.statRuleset ?? "wow", template.classId, ); const specializationId = specializationsForClass(template.classId) .find((definition) => definition.displayName === template.specialization)?.id ?? null; const gearStats = resolvedEquipmentStats({}, [], { ownerId: id, ownerKind: "party", classId: template.classId, secondaryClassId: safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null, specializationId, role, level: safeLevel, }); const secondaryClassId = safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null; const maxHealth = memberMaxHealth( safeLevel, role, gearStats, template.classId, race.id, statRuleset, secondaryClassId, gender, ); const resourceProfile = resourceProfileForClass(template.classId); const maxResource = memberMaxResource( template.classId, safeLevel, race.id, gearStats, statRuleset, secondaryClassId, gender, ); const resource = statRuleset === "rom" ? resourceProfile.initial === resourceProfile.maximum && resourceProfile.maximum > 0 ? maxResource : Math.max(0, Math.min(maxResource, Math.round( maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), ))) : Math.round( maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), ); return withMemberAbilityResourcePools({ id, name: template.name, raceId: race.id, gender, appearance, classId: template.classId, statRuleset, secondaryClassId, specialization: template.specialization, specializationId, talentTreeId: template.talentTreeId, talentProfileId: template.talentProfileId ?? null, talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId) ? Object.freeze({}) : runtimePartyTalentRanksForLevel(template.classId, template.specialization, safeLevel, template.talentProfileId ?? undefined)), role, level: safeLevel, health: maxHealth, maxHealth, resourceType: resourceProfile.type, resourceName: resourceProfile.name, resource, maxResource, resourcePools: Object.freeze({ [resourceProfile.type]: resource }), lastResourceSpentAt: Object.freeze({}), comboPoints: 0, comboTargetId: null, cooldowns: Object.freeze({}), globalCooldownEndsAt: 0, activeCast: null, activeCastTargetId: null, schoolLockedUntil: Object.freeze({}), runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]), gearStats, status: "ready", nextActionAt: 0, tauntReadyAt: 0, controlledUntil: 0, controlMechanic: null, }); } /** Creates one exact companion while retaining the live party stat/equipment path. */ export function createConfiguredPartyMember( configuration: ConfiguredPartyMemberSeed, level: number, index: number, seed: string, ): PartyMember { const member = createMember({ name: configuration.name, classId: configuration.classId, statRuleset: configuration.statRuleset ?? "wow", secondaryClassId: configuration.secondaryClassId, raceId: configuration.raceId, gender: configuration.gender, specialization: configuration.specialization, talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "", talentProfileId: configuration.talentProfileId ?? null, }, configuration.role, level, index, seed, configuration.talentRanks); return Object.freeze(withMemberAbilityResourcePools({ ...member, specializationId: configuration.specializationId ?? member.specializationId, talentRanks: configuration.talentRanks ?? member.talentRanks, glyphIds: configuration.classId === "druid" ? [...new Set((configuration.glyphIds ?? []).filter((glyphId) => glyphId === WOW335_GLYPH_OF_SWIFTMEND_ID))] : [], })); } /** * Builds the four AI slots around the player's selected role. The resulting * group is always a classic five-player composition including the player. */ export function createDungeonParty( seed: string, level: number, playerRole: PartyRole = "healer", partySize: PartySize = 5, categoryId: ContentCategoryId = "wow", options: CompanionPartyGenerationOptions = {}, ): readonly PartyMember[] { const random = { value: hashSeed(seed || "healer-man") }; const npcSeats = Math.max(0, partySize - 1); const humanRoleCounts: Record = { tank: 0, healer: 0, damage: 0 }; for (const role of options.occupiedHumanRoles?.length ? options.occupiedHumanRoles : [playerRole]) { humanRoleCounts[role] += 1; } const npcRoles = (["tank", "healer", "damage", "damage", "damage"] as const).filter((role) => { if (humanRoleCounts[role] <= 0) return true; humanRoleCounts[role] -= 1; return false; }).slice(0, npcSeats); const seatsByRole: Record = { tank: 0, healer: 0, damage: 0 }; for (const role of npcRoles) seatsByRole[role] += 1; const playerRomIdentity = categoryId === "rom" && options.playerRomClassPair?.secondaryClassId ? ROM_FIVE_IDENTITIES.find((identity) => ( identity.classId === options.playerRomClassPair?.primaryClassId && identity.secondaryClassId === options.playerRomClassPair?.secondaryClassId )) : undefined; const canUseRomComplement = Boolean(playerRomIdentity) && (Object.keys(seatsByRole) as PartyRole[]).every((role) => ( ROM_FIVE_IDENTITIES.filter((identity) => ( identity.role === role && !(identity.classId === playerRomIdentity?.classId && identity.secondaryClassId === playerRomIdentity?.secondaryClassId) )).length >= seatsByRole[role] )); const sourceForRole = (role: PartyRole): readonly PartyTemplate[] => { if (categoryId === "rom") { const templates = role === "tank" ? ROM_TANK_TEMPLATES : role === "healer" ? ROM_HEALER_TEMPLATES : ROM_DAMAGE_TEMPLATES; return canUseRomComplement && playerRomIdentity ? templates.filter((template) => ( template.classId !== playerRomIdentity.classId || template.secondaryClassId !== playerRomIdentity.secondaryClassId )) : templates; } return wowTemplates(role, options, seatsByRole[role]); }; const pools = Object.fromEntries((["tank", "healer", "damage"] as const).map((role) => { const pool = [...sourceForRole(role)]; for (let index = pool.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(nextRandom(random) * (index + 1)); [pool[index], pool[swapIndex]] = [pool[swapIndex], pool[index]]; } return [role, pool] as const; })) as Record; const usedByRole: Record = { tank: 0, healer: 0, damage: 0 }; return npcRoles.map((role, index) => { const pool = pools[role]; if (!pool.length) throw new Error(`No ${role} companion is available for this party.`); const template = pool[usedByRole[role] % pool.length]; usedByRole[role] += 1; return createMember(template, role, level, index, seed); }); } /** Builds a complete deterministic NPC group for GM spectator simulations. */ export function createSpectatorDungeonParty( seed: string, level: number, categoryId: ContentCategoryId = "wow", options: CompanionPartyGenerationOptions = {}, ): readonly PartyMember[] { const random = { value: hashSeed(seed || "healer-man-spectator") }; const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : wowTemplates("tank", options, 1); const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : wowTemplates("healer", options, 1); const damagePool = [...(categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : wowTemplates("damage", options, 3))]; for (let index = damagePool.length - 1; index > 0; index -= 1) { const swapIndex = Math.floor(nextRandom(random) * (index + 1)); [damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]]; } const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0]; const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)] ?? healerTemplates[0]; return [ createMember(tank, "tank", level, 0, seed), createMember(healer, "healer", level, 1, seed), ...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 2, seed)), ]; } export interface PartyRuntimeCommit { readonly members: readonly PartyMember[]; readonly activeMobId: string | null; readonly objectiveBossId: string | null; readonly memberTargetIds: Readonly>; } export interface PartyState { seed: string | null; spectatorParty: boolean; playerRole: PartyRole; partySize: PartySize; categoryId: ContentCategoryId; companionOptions: CompanionPartyGenerationOptions; members: readonly PartyMember[]; command: PartyCommand; selectedMemberId: string | null; selfSelected: boolean; routeBlocked: boolean; recallRevision: number; activeMobId: string | null; objectiveBossId: string | null; memberTargetIds: Readonly>; initializeParty: ( seed: string, level: number, playerHealth: number, playerRole?: PartyRole, partySize?: PartySize, categoryId?: ContentCategoryId, companionOptions?: CompanionPartyGenerationOptions, ) => void; initializeSpectatorParty: ( seed: string, level: number, categoryId?: ContentCategoryId, companionOptions?: CompanionPartyGenerationOptions, ) => void; resetCombat: (level: number, playerHealth: number) => void; syncLevel: (level: number) => void; syncEquipment: (equipment: EquipmentAssignments, inventory: readonly InventoryItem[]) => void; setCommand: (command: PartyCommand) => void; recallParty: () => void; setRouteBlocked: (blocked: boolean) => void; selectMember: (id: string | null) => boolean; selectSelf: () => void; cycleMember: (direction?: 1 | -1) => string | null; clearSelection: () => void; healMember: (id: string, amount: number) => number; updateMemberCombat: ( id: string, patch: Partial>, ) => boolean; reviveMember: (id: string, percentMaxHealth?: number) => number; damageMember: (id: string, amount: number, school?: DamageSchool, attackerLevel?: number) => number; /** Applies damage that has already passed a discrete combat-result roll. */ damageMemberResolved: (id: string, amount: number) => number; controlMember: (id: string, mechanic: EnemyControlMechanic, endsAt: number) => boolean; clearMemberControl: (id: string) => boolean; commitRuntime: (commit: PartyRuntimeCommit) => void; } export const usePartyStore = create((set, get) => ({ seed: null, spectatorParty: false, playerRole: "healer", partySize: 5, categoryId: "wow", companionOptions: Object.freeze({}), members: [], command: "defend", selectedMemberId: null, selfSelected: false, routeBlocked: false, recallRevision: 0, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, initializeParty: (seed, level, playerHealth, playerRole = "healer", partySize = 5, categoryId = "wow", companionOptions = {}) => set({ seed, spectatorParty: false, playerRole, partySize, categoryId, companionOptions, members: createDungeonParty(seed, level, playerRole, partySize, categoryId, companionOptions), command: "defend", selectedMemberId: null, selfSelected: false, routeBlocked: false, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, }), initializeSpectatorParty: (seed, level, categoryId = "wow", companionOptions = {}) => set({ seed, spectatorParty: true, playerRole: "healer", partySize: 5, categoryId, companionOptions, members: createSpectatorDungeonParty(seed, level, categoryId, companionOptions), command: "attack", selectedMemberId: null, selfSelected: false, routeBlocked: false, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, }), resetCombat: (level, playerHealth) => set((state) => { return { members: state.spectatorParty ? createSpectatorDungeonParty(state.seed ?? "healer-man-spectator", level, state.categoryId, state.companionOptions) : createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId, state.companionOptions), command: state.spectatorParty ? "attack" : "defend", selectedMemberId: null, selfSelected: false, routeBlocked: false, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, }; }), syncLevel: (level) => set((state) => { const safeLevel = clampPlayerLevel(level); if (state.members.every((member) => member.level === safeLevel)) return state; return { members: state.members.map((member) => { const nextSecondaryClassId = isRomClassId(member.classId) && romFiveIdentity(member.classId, member.role)?.name === member.name ? (safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL ? romFiveIdentity(member.classId, member.role)?.secondaryClassId ?? null : null) : member.secondaryClassId; const maxHealth = memberMaxHealth( safeLevel, member.role, member.gearStats, member.classId, member.raceId, member.statRuleset, nextSecondaryClassId, member.gender, ); const health = member.health <= 0 ? 0 : rescaledMemberValue( member.health, member.maxHealth, maxHealth, member.statRuleset === "rom", 1, ); const maxResource = memberMaxResource( member.classId, safeLevel, member.raceId, member.gearStats, member.statRuleset, nextSecondaryClassId, member.gender, ); const resource = rescaledMemberValue( member.resource, member.maxResource, maxResource, member.statRuleset === "rom", 0, ); return withMemberAbilityResourcePools({ ...member, resourceMaximums: undefined, level: safeLevel, secondaryClassId: nextSecondaryClassId, health, maxHealth, resource, maxResource, resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), talentRanks: isRomClassId(member.classId) ? Object.freeze({}) : runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel, member.talentProfileId ?? undefined), }); }), }; }), syncEquipment: (assignments, inventory) => set((state) => ({ members: state.members.map((member) => withMemberGearStats( member, resolvedEquipmentStats(assignments, inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, }), )), })), setCommand: (command) => set((state) => ({ command, routeBlocked: false, activeMobId: null, memberTargetIds: {}, members: state.members.map((member) => member.health <= 0 ? member : { ...member, status: command === "stop" ? "holding" : "ready", nextActionAt: 0 }), })), recallParty: () => set((state) => ({ recallRevision: state.recallRevision + 1, routeBlocked: false, })), setRouteBlocked: (blocked) => set((state) => ( state.routeBlocked === blocked ? state : { routeBlocked: blocked } )), selectMember: (id) => { if (id === null) { set({ selectedMemberId: null, selfSelected: false }); return true; } const member = get().members.find((candidate) => candidate.id === id); if (!member) return false; set({ selectedMemberId: id, selfSelected: false }); return true; }, selectSelf: () => set({ selectedMemberId: null, selfSelected: true }), cycleMember: (direction = 1) => { const state = get(); const selfToken = "__self__"; const available = [ ...state.members.filter((member) => member.health > 0).map((member) => member.id), selfToken, ]; const currentId = state.selfSelected ? selfToken : state.selectedMemberId; const currentIndex = available.findIndex((id) => id === currentId); const step = direction < 0 ? -1 : 1; const nextIndex = currentIndex < 0 ? (step > 0 ? 0 : available.length - 1) : (currentIndex + step + available.length) % available.length; const id = available[nextIndex]; if (id === selfToken) { set({ selectedMemberId: null, selfSelected: true }); return null; } set({ selectedMemberId: id, selfSelected: false }); return id; }, clearSelection: () => set({ selectedMemberId: null, selfSelected: false }), healMember: (id, amount) => { const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (requested <= 0) return 0; let healed = 0; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.health <= 0 || member.health >= member.maxHealth) return member; healed = Math.min(requested, member.maxHealth - member.health); return { ...member, health: member.health + healed }; }), })); return healed; }, updateMemberCombat: (id, patch) => { let updated = false; set((state) => ({ members: state.members.map((member) => { if (member.id !== id) return member; updated = true; return { ...member, ...patch }; }), })); return updated; }, reviveMember: (id, percentMaxHealth = 0.35) => { let restored = 0; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.health > 0) return member; restored = Math.max(1, Math.round( member.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)), )); return { ...member, health: restored, romNativeRecoveryClock: undefined, status: "ready", controlledUntil: 0, controlMechanic: null, }; }), })); return restored; }, damageMember: (id, amount, school = "physical", attackerLevel) => { const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (requested <= 0) return 0; let damaged = 0; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.health <= 0) return member; const mitigated = damageAfterEquipmentMitigation( requested, member.gearStats, member.level, school, attackerLevel ?? member.level, ); damaged = Math.min(mitigated, member.health); const health = member.health - damaged; return { ...member, health, romNativeRecoveryClock: health <= 0 ? undefined : member.romNativeRecoveryClock, status: health <= 0 ? "down" : member.status, }; }), })); return damaged; }, damageMemberResolved: (id, amount) => { const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); if (requested <= 0) return 0; let damaged = 0; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.health <= 0) return member; damaged = Math.min(requested, member.health); const health = member.health - damaged; return { ...member, health, status: health <= 0 ? "down" : member.status, }; }), })); return damaged; }, controlMember: (id, mechanic, endsAt) => { if (!Number.isFinite(endsAt)) return false; let controlled = false; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.health <= 0) return member; controlled = true; return { ...member, controlledUntil: Math.max(member.controlledUntil, endsAt), controlMechanic: mechanic, status: "holding", }; }), })); return controlled; }, clearMemberControl: (id) => { let cleared = false; set((state) => ({ members: state.members.map((member) => { if (member.id !== id || member.controlledUntil <= 0) return member; cleared = true; return { ...member, controlledUntil: 0, controlMechanic: null, status: member.health <= 0 ? "down" : "ready", }; }), })); return cleared; }, commitRuntime: ({ members, activeMobId, objectiveBossId, memberTargetIds }) => set((state) => ({ members, selectedMemberId: state.selectedMemberId && members.some((member) => member.id === state.selectedMemberId) ? state.selectedMemberId : null, activeMobId, objectiveBossId, memberTargetIds, })), }));