Files
healer-man/src/game/partyStore.ts
T
2026-09-12 23:55:24 -04:00

714 lines
25 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 { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories";
import type { PartyRole, PartySize } from "./partyRoles";
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
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";
export interface PartyMember {
readonly id: string;
readonly name: string;
readonly raceId: RaceId;
readonly gender: GenderId;
readonly appearance: CharacterAppearance;
readonly classId: ClassId;
readonly specialization: string;
readonly specializationId: string | null;
readonly talentTreeId: string;
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 cooldowns: Readonly<Record<string, number>>;
readonly globalCooldownEndsAt: number;
readonly activeCast: CombatActorCastState | 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 specialization: string;
readonly talentTreeId: string;
}
const TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Garrick", classId: "warrior", specialization: "Protection", talentTreeId: "warrior-protection" },
{ name: "Morvane", classId: "death-knight", specialization: "Blood", talentTreeId: "death-knight-blood" },
{ name: "Seraphin", classId: "paladin", specialization: "Protection", talentTreeId: "paladin-protection" },
]);
const HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Elowen", classId: "priest", specialization: "Holy", talentTreeId: "priest-holy" },
{ name: "Lucian", classId: "paladin", specialization: "Holy", talentTreeId: "paladin-holy" },
{ name: "Nym", classId: "druid", specialization: "Restoration", talentTreeId: "druid-restoration" },
{ name: "Tala", classId: "shaman", specialization: "Restoration", talentTreeId: "shaman-restoration" },
]);
/** Every entry is a Wrath damage specialization; tank and healer specs are excluded. */
const DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Brynn", classId: "warrior", specialization: "Arms", talentTreeId: "warrior-arms" },
{ name: "Kael", classId: "warrior", specialization: "Fury", talentTreeId: "warrior-fury" },
{ name: "Aurelia", classId: "paladin", specialization: "Retribution", talentTreeId: "paladin-retribution" },
{ name: "Tamsin", classId: "hunter", specialization: "Marksmanship", talentTreeId: "hunter-marksmanship" },
{ name: "Fen", classId: "hunter", specialization: "Survival", talentTreeId: "hunter-survival" },
{ name: "Vale", classId: "rogue", specialization: "Combat", talentTreeId: "rogue-combat" },
{ name: "Nyx", classId: "rogue", specialization: "Assassination", talentTreeId: "rogue-assassination" },
{ name: "Selene", classId: "priest", specialization: "Shadow", talentTreeId: "priest-shadow" },
{ name: "Varek", classId: "death-knight", specialization: "Frost", talentTreeId: "death-knight-frost" },
{ name: "Thorne", classId: "death-knight", specialization: "Unholy", talentTreeId: "death-knight-unholy" },
{ name: "Korra", classId: "shaman", specialization: "Enhancement", talentTreeId: "shaman-enhancement" },
{ name: "Orin", classId: "shaman", specialization: "Elemental", talentTreeId: "shaman-elemental" },
{ name: "Mira", classId: "mage", specialization: "Arcane", talentTreeId: "mage-arcane" },
{ name: "Ember", classId: "mage", specialization: "Fire", talentTreeId: "mage-fire" },
{ name: "Ilyra", classId: "mage", specialization: "Frost", talentTreeId: "mage-frost" },
{ name: "Malric", classId: "warlock", specialization: "Affliction", talentTreeId: "warlock-affliction" },
{ name: "Sable", classId: "warlock", specialization: "Demonology", talentTreeId: "warlock-demonology" },
{ name: "Rowan", classId: "druid", specialization: "Balance", talentTreeId: "druid-balance" },
{ name: "Ash", classId: "druid", specialization: "Feral (Cat)", talentTreeId: "druid-feral-combat" },
]);
const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Aldric", classId: "rom-warrior", 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([
{ name: "Liora", classId: "rom-priest", specialization: "Primary", talentTreeId: "" },
{ name: "Sylva", classId: "rom-druid", specialization: "Primary", talentTreeId: "" },
]);
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Cael", classId: "rom-scout", specialization: "Primary", talentTreeId: "" },
{ name: "Veya", classId: "rom-rogue", specialization: "Primary", talentTreeId: "" },
{ name: "Ilyen", classId: "rom-mage", specialization: "Primary", talentTreeId: "" },
{ name: "Morgha", classId: "rom-warlock", specialization: "Primary", talentTreeId: "" },
{ name: "Roran", classId: "rom-knight", specialization: "Primary", talentTreeId: "" },
]);
export const TANK_SPECIALIZATIONS = Object.freeze(new Set([
"warrior:Protection",
"death-knight:Blood",
"paladin:Protection",
]));
export function isTankSpecialization(classId: ClassId, specialization: string): boolean {
return TANK_SPECIALIZATIONS.has(`${classId}:${specialization}`);
}
export const HEALER_SPECIALIZATIONS = Object.freeze(new Set([
"priest:Holy",
"paladin:Holy",
"druid:Restoration",
"shaman:Restoration",
]));
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 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 {
...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,
): 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,
));
const racePool = eligibleRaces.length ? eligibleRaces : PLAYABLE_RACES;
const race = 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,
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 {
id,
name: template.name,
raceId: race.id,
gender,
appearance,
classId: template.classId,
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
talentRanks: isRomClassId(template.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level),
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({}),
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,
};
}
/**
* 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",
): readonly PartyMember[] {
const random = { value: hashSeed(seed || "healer-man") };
const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : TANK_TEMPLATES;
const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : HEALER_TEMPLATES;
const damageTemplates = categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : DAMAGE_TEMPLATES;
const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0];
const damagePool = [...damageTemplates];
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 healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)]
?? healerTemplates[0];
if (playerRole === "tank") {
return [
createMember(healer, "healer", level, 0, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)),
].slice(0, Math.max(0, partySize - 1));
}
if (playerRole === "damage") {
return [
createMember(tank, "tank", level, 0, seed),
createMember(healer, "healer", level, 1, seed),
...damagePool.slice(0, 2).map((template, index) => createMember(template, "damage", level, index + 2, seed)),
].slice(0, Math.max(0, partySize - 1));
}
return [
createMember(tank, "tank", level, 0, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)),
].slice(0, Math.max(0, partySize - 1));
}
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;
playerRole: PartyRole;
partySize: PartySize;
categoryId: ContentCategoryId;
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,
) => 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,
playerRole: "healer",
partySize: 5,
categoryId: "wow",
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") => set({
seed,
playerRole,
partySize,
categoryId,
members: createDungeonParty(seed, level, playerRole, partySize, categoryId),
command: "defend",
selectedMemberId: null,
selfSelected: false,
routeBlocked: false,
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
}),
resetCombat: (level, playerHealth) => set((state) => {
return {
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
command: "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 {
...member,
level: safeLevel,
health,
maxHealth,
resource,
maxResource,
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
talentRanks: isRomClassId(member.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
};
}),
};
}),
syncEquipment: (assignments, inventory) => set((state) => ({
members: state.members.map((member) => withMemberGearStats(
member,
resolvedEquipmentStats(assignments, inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
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,
})),
}));