916 lines
33 KiB
TypeScript
916 lines
33 KiB
TypeScript
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 {
|
|
damageAfterEquipmentMitigation,
|
|
deriveCharacterStats,
|
|
} from "./wotlkStats";
|
|
import type { InventoryItem } from "./lootTypes";
|
|
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 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 { 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;
|
|
/** 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;
|
|
};
|
|
|
|
export interface PartyMember {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly raceId: RaceId;
|
|
readonly gender: GenderId;
|
|
readonly appearance: CharacterAppearance;
|
|
readonly classId: ClassId;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly specialization: string;
|
|
readonly specializationId: string | null;
|
|
readonly talentTreeId: string;
|
|
readonly talentProfileId: string | null;
|
|
readonly talentRanks: TalentRanks;
|
|
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<Partial<Record<ResourceType, number>>>;
|
|
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
|
readonly warriorAngerManagementRageRemainderMs?: number;
|
|
readonly comboPoints: number;
|
|
readonly comboTargetId: string | null;
|
|
readonly cooldowns: Readonly<Record<string, number>>;
|
|
readonly globalCooldownEndsAt: number;
|
|
readonly activeCast: PartyActiveCast | null;
|
|
readonly activeCastTargetId: string | null;
|
|
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
|
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 secondaryClassId?: ClassId | null;
|
|
readonly raceId?: RaceId;
|
|
readonly specialization: string;
|
|
readonly talentTreeId: string;
|
|
readonly talentProfileId?: string | null;
|
|
}
|
|
|
|
export interface ConfiguredPartyMemberSeed {
|
|
readonly name: string;
|
|
readonly classId: ClassId;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly raceId?: RaceId;
|
|
readonly specialization: string;
|
|
readonly specializationId?: string | null;
|
|
readonly talentTreeId?: string;
|
|
readonly talentProfileId?: string | null;
|
|
readonly talentRanks?: TalentRanks;
|
|
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[];
|
|
}
|
|
|
|
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([
|
|
{ name: "Aldric", classId: "rom-warrior", specialization: "Primary", talentTreeId: "" },
|
|
{ ...romFiveIdentity("rom-knight", "tank")!, specialization: "Primary", talentTreeId: "" },
|
|
{ name: "Maerwen", classId: "rom-warden", specialization: "Primary", talentTreeId: "" },
|
|
{ name: "Bronn", classId: "rom-champion", specialization: "Primary", talentTreeId: "" },
|
|
]);
|
|
const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
|
{ ...romFiveIdentity("rom-priest", "healer")!, specialization: "Primary", talentTreeId: "" },
|
|
{ name: "Sylva", classId: "rom-druid", specialization: "Primary", talentTreeId: "" },
|
|
]);
|
|
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
|
{ ...romFiveIdentity("rom-scout", "damage")!, specialization: "Primary", talentTreeId: "" },
|
|
{ ...romFiveIdentity("rom-rogue", "damage")!, specialization: "Primary", talentTreeId: "" },
|
|
{ ...romFiveIdentity("rom-mage", "damage")!, specialization: "Primary", talentTreeId: "" },
|
|
{ name: "Morgha", classId: "rom-warlock", 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",
|
|
): number {
|
|
const safeLevel = Math.max(1, Math.trunc(level));
|
|
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,
|
|
): number {
|
|
const profile = resourceProfileForClass(classId);
|
|
return profile.type === "mana"
|
|
? deriveCharacterStats(classId, level, gearStats, raceId).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;
|
|
return 100;
|
|
}
|
|
|
|
function initialSecondaryResource(type: ResourceType, maximum: number): number {
|
|
return type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
|
? 0
|
|
: maximum;
|
|
}
|
|
|
|
/** Keeps every resource used by the member's learned kit available to companion AI. */
|
|
function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
|
|
const types = new Set<ResourceType>([member.resourceType]);
|
|
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<Record<ResourceType, number>> = {};
|
|
for (const type of types) {
|
|
if (type === member.resourceType) {
|
|
resourcePools[type] = member.resource;
|
|
continue;
|
|
}
|
|
const maximum = secondaryResourceMaximum(type, member);
|
|
resourcePools[type] = Math.max(0, Math.min(
|
|
maximum,
|
|
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
|
|
));
|
|
}
|
|
return { ...member, resourcePools: Object.freeze(resourcePools) };
|
|
}
|
|
|
|
function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
|
|
const maxHealth = memberMaxHealth(
|
|
member.level,
|
|
member.role,
|
|
gearStats,
|
|
member.classId,
|
|
member.raceId,
|
|
);
|
|
const health = member.health <= 0
|
|
? 0
|
|
: Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth));
|
|
const maxResource = memberMaxResource(member.classId, member.level, member.raceId, gearStats);
|
|
const resource = Math.max(0, Math.round(
|
|
member.resource / Math.max(1, member.maxResource) * maxResource,
|
|
));
|
|
return withMemberAbilityResourcePools({
|
|
...member,
|
|
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 {
|
|
// 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 = 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 specializationId = specializationsForClass(template.classId)
|
|
.find((definition) => definition.displayName === template.specialization)?.id ?? null;
|
|
const gearStats = resolvedEquipmentStats({}, [], {
|
|
ownerId: id,
|
|
ownerKind: "party",
|
|
classId: template.classId,
|
|
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
|
|
specializationId,
|
|
role,
|
|
level,
|
|
});
|
|
const maxHealth = memberMaxHealth(level, role, gearStats, template.classId, race.id);
|
|
const resourceProfile = resourceProfileForClass(template.classId);
|
|
const maxResource = memberMaxResource(template.classId, level, race.id, gearStats);
|
|
const resource = 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,
|
|
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
|
|
specialization: template.specialization,
|
|
specializationId,
|
|
talentTreeId: template.talentTreeId,
|
|
talentProfileId: template.talentProfileId ?? null,
|
|
talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId)
|
|
? Object.freeze({})
|
|
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level, template.talentProfileId ?? undefined)),
|
|
role,
|
|
level: Math.max(1, Math.trunc(level)),
|
|
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,
|
|
secondaryClassId: configuration.secondaryClassId,
|
|
raceId: configuration.raceId,
|
|
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,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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<PartyRole, number> = { 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<PartyRole, number> = { tank: 0, healer: 0, damage: 0 };
|
|
for (const role of npcRoles) seatsByRole[role] += 1;
|
|
const sourceForRole = (role: PartyRole): readonly PartyTemplate[] => {
|
|
if (categoryId === "rom") {
|
|
return role === "tank" ? ROM_TANK_TEMPLATES : role === "healer" ? ROM_HEALER_TEMPLATES : ROM_DAMAGE_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<PartyRole, PartyTemplate[]>;
|
|
const usedByRole: Record<PartyRole, number> = { 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<Record<string, string>>;
|
|
}
|
|
|
|
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<Record<string, string>>;
|
|
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<Pick<PartyMember,
|
|
| "resource"
|
|
| "maxResource"
|
|
| "resourcePools"
|
|
| "lastResourceSpentAt"
|
|
| "cooldowns"
|
|
| "globalCooldownEndsAt"
|
|
| "activeCast"
|
|
| "activeCastTargetId"
|
|
| "schoolLockedUntil"
|
|
| "runes"
|
|
| "health"
|
|
| "maxHealth"
|
|
| "controlledUntil"
|
|
| "controlMechanic"
|
|
| "nextActionAt"
|
|
| "status"
|
|
>>,
|
|
) => 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<PartyState>((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 = Math.max(1, Math.trunc(level));
|
|
if (state.members.every((member) => member.level === safeLevel)) return state;
|
|
return {
|
|
members: state.members.map((member) => {
|
|
const maxHealth = memberMaxHealth(
|
|
safeLevel,
|
|
member.role,
|
|
member.gearStats,
|
|
member.classId,
|
|
member.raceId,
|
|
);
|
|
const health = member.health <= 0
|
|
? 0
|
|
: Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth));
|
|
const maxResource = memberMaxResource(
|
|
member.classId,
|
|
safeLevel,
|
|
member.raceId,
|
|
member.gearStats,
|
|
);
|
|
const resource = Math.max(0, Math.round(
|
|
member.resource / Math.max(1, member.maxResource) * maxResource,
|
|
));
|
|
return withMemberAbilityResourcePools({
|
|
...member,
|
|
level: safeLevel,
|
|
secondaryClassId: 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,
|
|
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,
|
|
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,
|
|
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,
|
|
})),
|
|
}));
|