import { beforeEach, describe, expect, it } from "vitest"; import { APPEARANCE_KEYS, ROM_CLASS_IDS, classesForRace, raceById, supportedGenders, } from "../app/characterCatalog"; import { createDungeonParty, createConfiguredPartyMember, isHealerSpecialization, isTankSpecialization, usePartyStore, } from "./partyStore"; import { spentTalentPoints, talentPointsForLevel, talentTreeById } from "./talentCatalog"; import type { InventoryItem } from "./lootTypes"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { DEFAULT_COMPANION_SPEC_READINESS_POLICY } from "./companionSpecReadiness"; describe("dungeon party composition", () => { beforeEach(() => usePartyStore.setState({ seed: null, playerRole: "healer", members: [], command: "defend", selectedMemberId: null, selfSelected: false, routeBlocked: false, recallRevision: 0, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, })); it("creates one supported tank and three damage dealers when the player heals", () => { for (const seed of ["priest-a", "priest-b", "priest-c", "priest-d"]) { const party = createDungeonParty(seed, 12); expect(party).toHaveLength(4); expect(party.filter((member) => member.role === "tank")).toHaveLength(1); expect(party.filter((member) => member.role === "damage")).toHaveLength(3); const tank = party.find((member) => member.role === "tank")!; expect(isTankSpecialization(tank.classId, tank.specialization)).toBe(true); expect(party.filter((member) => member.role === "damage").every( (member) => !isTankSpecialization(member.classId, member.specialization), )).toBe(true); } }); it("uses only the five Ready Playerbots identities by default", () => { const party = createDungeonParty("ready-only", 80, "damage"); expect(party.map((member) => member.name)).toEqual(expect.arrayContaining(["Aldren", "Quorin"])); expect(party.every((member) => ["Aldren", "Quorin", "Liora", "Xavian", "Orin"].includes(member.name))).toBe(true); expect(new Set(party.filter((member) => member.role === "damage").map((member) => member.specializationId)).size) .toBe(party.filter((member) => member.role === "damage").length); }); it("adds Beta specs only when opted in and still excludes Needs Testing", () => { const policy = { schemaVersion: 1 as const, statuses: { ...DEFAULT_COMPANION_SPEC_READINESS_POLICY.statuses, "mage:arcane": "beta" as const, }, }; const defaultNames = new Set(); const betaNames = new Set(); for (let seed = 0; seed < 40; seed += 1) { for (const member of createDungeonParty(`beta-${seed}`, 80, "damage", 5, "wow", { readinessPolicy: policy })) { defaultNames.add(member.specializationId ?? ""); } for (const member of createDungeonParty(`beta-${seed}`, 80, "damage", 5, "wow", { readinessPolicy: policy, includeBetaSpecs: true })) { betaNames.add(member.specializationId ?? ""); } } expect(defaultNames.has("mage:arcane")).toBe(false); expect(betaNames.has("mage:arcane")).toBe(true); expect(betaNames.has("mage:frost")).toBe(false); }); it("avoids the player's damage specialization when two alternatives can fill the NPC seats", () => { const party = createDungeonParty("exclude-player", 80, "damage", 5, "wow", { excludedPlayerSpecializationId: "mage:fire", }); expect(party.filter((member) => member.role === "damage").map((member) => member.specializationId).sort()) .toEqual(["hunter:marksmanship", "rogue:assassination"]); }); it("fills only missing roles around every real player in an online five-player party", () => { const options = { occupiedHumanRoles: ["healer", "damage"] as const, excludedPlayerSpecializationIds: ["priest:discipline", "mage:fire"], allowedCompanionSpecializationIds: [ "warrior:protection", "priest:discipline", "hunter:marksmanship", "mage:fire", "rogue:assassination", ], }; const leaderParty = createDungeonParty("shared-activity", 20, "healer", 4, "wow", options); const memberParty = createDungeonParty("shared-activity", 20, "damage", 4, "wow", options); expect(memberParty).toEqual(leaderParty); expect(leaderParty).toHaveLength(3); expect(leaderParty.map((member) => member.role).sort()).toEqual(["damage", "damage", "tank"]); expect(leaderParty.every((member) => options.allowedCompanionSpecializationIds.includes(member.specializationId!))).toBe(true); }); it("uses two damage NPCs when three real players already cover tank, healer, and damage", () => { const party = createDungeonParty("three-player-activity", 20, "tank", 3, "wow", { occupiedHumanRoles: ["tank", "healer", "damage"], allowedCompanionSpecializationIds: [ "warrior:protection", "priest:discipline", "hunter:marksmanship", "mage:fire", "rogue:assassination", ], }); expect(party).toHaveLength(2); expect(party.every((member) => member.role === "damage")).toBe(true); }); it("keeps RoM generation unchanged when WoW readiness is restrictive", () => { const baseline = createDungeonParty("rom-readiness", 40, "damage", 5, "rom"); const filtered = createDungeonParty("rom-readiness", 40, "damage", 5, "rom", { readinessPolicy: DEFAULT_COMPANION_SPEC_READINESS_POLICY, }); expect(filtered).toEqual(baseline); }); it("fills a dedicated healer when the player chooses tank or damage", () => { for (const playerRole of ["tank", "damage"] as const) { for (const seed of ["role-a", "role-b", "role-c", "role-d"]) { const party = createDungeonParty(seed, 32, playerRole); const healers = party.filter((member) => member.role === "healer"); expect(party).toHaveLength(4); expect(healers).toHaveLength(1); expect(healers[0].classId).toMatch(/^(priest|paladin|druid|shaman)$/); expect(isHealerSpecialization(healers[0].classId, healers[0].specialization)).toBe(true); expect(talentTreeById(healers[0].talentTreeId)?.classId).toBe(healers[0].classId); expect(spentTalentPoints(healers[0].talentRanks, healers[0].classId)).toBe(talentPointsForLevel(32)); expect(party.filter((member) => member.role === "tank")).toHaveLength(playerRole === "tank" ? 0 : 1); expect(party.filter((member) => member.role === "damage")).toHaveLength(playerRole === "tank" ? 3 : 2); } } }); it("gives mana-using companions real pools that can pay fixed spell-rank costs", () => { const party = createDungeonParty("fixed-rank-mana", 32, "damage"); const manaUsers = party.filter((member) => member.resourceType === "mana"); expect(manaUsers.length).toBeGreaterThan(0); for (const member of manaUsers) { expect(member.maxResource).toBeGreaterThan(100); expect(member.resource).toBe(member.maxResource); expect(member.resourcePools.mana).toBe(member.maxResource); } }); it("creates the secondary Rage and Energy pools used by druid forms", () => { const bear = createConfiguredPartyMember({ name: "Balance Bear", classId: "druid", specialization: "Feral (Bear)", specializationId: "druid:feral-bear", talentTreeId: "druid-feral-bear", talentRanks: {}, role: "tank", }, 80, 0, "feral-resources"); const cat = createConfiguredPartyMember({ name: "Balance Cat", classId: "druid", specialization: "Feral (Cat)", specializationId: "druid:feral-cat", talentTreeId: "druid-feral-cat", talentRanks: {}, role: "damage", }, 80, 1, "feral-resources"); expect(bear.resourcePools).toMatchObject({ mana: bear.maxResource, rage: 0, energy: 100 }); expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 }); }); it("is deterministic per character and scales every ally to the player level", () => { const first = createDungeonParty("character-42", 18); const second = createDungeonParty("character-42", 18); expect(second).toEqual(first); expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true); }); it("syncs every RoM companion class through entry and level changes without WoW talents", () => { const seenClasses = new Set(); for (const role of ["tank", "healer", "damage"] as const) { for (let seed = 0; seed < 20; seed += 1) { usePartyStore.getState().initializeParty(`rom-level-${seed}`, 1, 100, role, 5, "rom"); const identities = usePartyStore.getState().members.map(({ id, classId, raceId, gender, appearance }) => ({ id, classId, raceId, gender, appearance, })); for (const level of [2, 10, 70, 1]) { usePartyStore.getState().syncLevel(level); const members = usePartyStore.getState().members; expect(members.map(({ id, classId, raceId, gender, appearance }) => ({ id, classId, raceId, gender, appearance, }))).toEqual(identities); for (const member of members) { seenClasses.add(member.classId); expect(member.level).toBe(level); expect(member.talentRanks).toEqual({}); expect(Object.isFrozen(member.talentRanks)).toBe(true); expect(member.health).toBe(member.maxHealth); expect(member.resourcePools[member.resourceType]).toBe(member.resource); } } } } expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort()); }); it("preserves fallen RoM companions and living health/resource ratios on level sync", () => { usePartyStore.getState().initializeParty("rom-level-ratios", 10, 100, "damage", 5, "rom"); const [fallen, healer] = usePartyStore.getState().members; usePartyStore.getState().updateMemberCombat(fallen.id, { health: 0, status: "down" }); usePartyStore.getState().updateMemberCombat(healer.id, { health: Math.round(healer.maxHealth * 0.5), resource: Math.round(healer.maxResource * 0.25), }); const before = usePartyStore.getState().members[1]; usePartyStore.getState().syncLevel(11); const [syncedFallen, syncedHealer] = usePartyStore.getState().members; expect(syncedFallen).toMatchObject({ health: 0, status: "down", level: 11, talentRanks: {} }); expect(syncedHealer.health).toBe(Math.round(before.health / before.maxHealth * syncedHealer.maxHealth)); expect(syncedHealer.resource).toBe(Math.round(before.resource / before.maxResource * syncedHealer.maxResource)); expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource); }); it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => { expect(createDungeonParty("sizes", 18)).toHaveLength(4); for (const size of [1, 2, 3, 4, 5] as const) { expect(createDungeonParty("sizes", 18, "damage", size)).toHaveLength(size - 1); usePartyStore.getState().initializeParty("sizes", 18, 100, "damage", size); expect(usePartyStore.getState()).toMatchObject({ partySize: size }); expect(usePartyStore.getState().members).toHaveLength(size - 1); usePartyStore.getState().resetCombat(19, 100); expect(usePartyStore.getState().members).toHaveLength(size - 1); } }); it("gives every ally a supported race, gender, and in-range randomized appearance", () => { const identities = new Set(); for (let seedIndex = 0; seedIndex < 20; seedIndex += 1) { for (const member of createDungeonParty(`appearance-${seedIndex}`, 12)) { const race = raceById(member.raceId); expect(["female", "male"]).toContain(member.gender); expect(supportedGenders(member.raceId)).toContain(member.gender); expect(classesForRace(member.raceId).some((candidate) => candidate.id === member.classId)).toBe(true); for (const key of APPEARANCE_KEYS) { expect(member.appearance[key]).toBeGreaterThanOrEqual(0); expect(member.appearance[key]).toBeLessThan(race.counts[member.gender][key]); } identities.add(`${member.raceId}:${member.gender}:${APPEARANCE_KEYS.map((key) => member.appearance[key]).join(":")}`); } } expect(identities.size).toBeGreaterThan(20); }); it("keeps generated identities stable across combat resets", () => { usePartyStore.getState().initializeParty("stable-identity", 12, 100); const before = usePartyStore.getState().members.map(({ id, raceId, gender, appearance }) => ({ id, raceId, gender, appearance, })); usePartyStore.getState().resetCombat(13, 100); const after = usePartyStore.getState().members.map(({ id, raceId, gender, appearance }) => ({ id, raceId, gender, appearance, })); expect(after).toEqual(before); }); it("cycles living click-targetable allies and the player self frame", () => { usePartyStore.getState().initializeParty("target-cycle", 5, 100); const first = usePartyStore.getState().cycleMember(1); const second = usePartyStore.getState().cycleMember(1); expect(first).toBe(usePartyStore.getState().members[0].id); expect(second).toBe(usePartyStore.getState().members[1].id); expect(usePartyStore.getState().cycleMember(-1)).toBe(first); const last = usePartyStore.getState().members.at(-1)!; usePartyStore.getState().selectMember(last.id); expect(usePartyStore.getState().cycleMember(1)).toBeNull(); expect(usePartyStore.getState().selfSelected).toBe(true); }); it("keeps repeated clicks on the same living ally targeted", () => { usePartyStore.getState().initializeParty("click-target", 5, 100); const target = usePartyStore.getState().members[0]; expect(usePartyStore.getState().selectMember(target.id)).toBe(true); expect(usePartyStore.getState().selectMember(target.id)).toBe(true); expect(usePartyStore.getState().selectedMemberId).toBe(target.id); }); it("tracks an explicit self target separately from ally selection", () => { usePartyStore.getState().initializeParty("self-target", 5, 100); const ally = usePartyStore.getState().members[0]; expect(usePartyStore.getState().selectMember(ally.id)).toBe(true); usePartyStore.getState().selectSelf(); expect(usePartyStore.getState()).toMatchObject({ selectedMemberId: null, selfSelected: true, }); expect(usePartyStore.getState().cycleMember(1)).toBe(ally.id); expect(usePartyStore.getState()).toMatchObject({ selectedMemberId: ally.id, selfSelected: false, }); usePartyStore.getState().selectSelf(); usePartyStore.getState().clearSelection(); expect(usePartyStore.getState()).toMatchObject({ selectedMemberId: null, selfSelected: false, }); }); it("starts in defend mode and applies explicit attack, defend, and stop commands", () => { usePartyStore.getState().initializeParty("commands", 5, 100); expect(usePartyStore.getState().command).toBe("defend"); usePartyStore.getState().setCommand("attack"); expect(usePartyStore.getState().command).toBe("attack"); usePartyStore.getState().setCommand("stop"); expect(usePartyStore.getState().command).toBe("stop"); expect(usePartyStore.getState().members.every((member) => member.status === "holding")).toBe(true); usePartyStore.getState().setCommand("defend"); expect(usePartyStore.getState().command).toBe("defend"); }); it("clears stale route-blocked feedback when a new party order is issued", () => { usePartyStore.getState().initializeParty("route-status", 5, 100); usePartyStore.getState().setRouteBlocked(true); expect(usePartyStore.getState().routeBlocked).toBe(true); usePartyStore.getState().setCommand("attack"); expect(usePartyStore.getState().routeBlocked).toBe(false); }); it("requests an immediate recall without changing the current party order", () => { usePartyStore.getState().initializeParty("recall-party", 5, 100); usePartyStore.getState().setCommand("attack"); usePartyStore.getState().setRouteBlocked(true); const before = usePartyStore.getState().recallRevision; usePartyStore.getState().recallParty(); expect(usePartyStore.getState()).toMatchObject({ command: "attack", recallRevision: before + 1, routeBlocked: false, }); }); it("keeps every party member at the player's live level while preserving health percentage", () => { usePartyStore.getState().initializeParty("level-sync", 10, 100); const original = usePartyStore.getState().members; usePartyStore.setState({ members: original.map((member, index) => index === 0 ? { ...member, health: Math.round(member.maxHealth / 2) } : member), }); usePartyStore.getState().syncLevel(11); const leveled = usePartyStore.getState().members; expect(leveled.every((member) => member.level === 11)).toBe(true); expect(leveled.map((member) => member.id)).toEqual(original.map((member) => member.id)); expect(leveled[0].health / leveled[0].maxHealth).toBeCloseTo(0.5, 1); }); it("heals a living member without exceeding maximum health or resurrecting a downed member", () => { usePartyStore.getState().initializeParty("healing", 10, 100); const [target, downed] = usePartyStore.getState().members; usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === target.id ? { ...member, health: member.maxHealth - 25 } : member.id === downed.id ? { ...member, health: 0, status: "down" } : member), })); expect(usePartyStore.getState().healMember(target.id, 10)).toBe(10); expect(usePartyStore.getState().healMember(target.id, 99)).toBe(15); expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health).toBe(target.maxHealth); expect(usePartyStore.getState().healMember(downed.id, 50)).toBe(0); expect(usePartyStore.getState().members.find((member) => member.id === downed.id)?.health).toBe(0); }); it("damages the exact living companion and downs them at zero health", () => { usePartyStore.getState().initializeParty("damage-member", 10, 100); const [target, untouched] = usePartyStore.getState().members; const firstDamage = usePartyStore.getState().damageMember(target.id, 25); expect(firstDamage).toBeGreaterThan(0); expect(firstDamage).toBeLessThanOrEqual(25); expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health) .toBe(target.maxHealth - firstDamage); expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.health) .toBe(untouched.maxHealth); expect(usePartyStore.getState().damageMember(target.id, target.maxHealth * 100)).toBe(target.maxHealth - firstDamage); expect(usePartyStore.getState().members.find((member) => member.id === target.id)).toMatchObject({ health: 0, status: "down", }); }); it("applies companion stamina and armor to live health and damage taken", () => { usePartyStore.getState().initializeParty("equipped-companion", 10, 100); const target = usePartyStore.getState().members[0]; const chest: InventoryItem = { instanceId: "companion-chest", itemId: 1, itemKey: "companion-chest", name: "Companion Chest", quality: "rare", inventorySlot: "chest", armorType: "cloth", weaponType: null, itemLevel: 10, sourceItemLevel: 20, stats: { ...EMPTY_ITEM_STATS, strength: 5, stamina: 4, armor: 100 }, quantity: 1, sourceBossKey: "test", sourceBossName: "Test Boss", acquiredAt: 1, }; const beforeMaxHealth = target.maxHealth; usePartyStore.getState().syncEquipment({ [target.id]: { chest: chest.instanceId }, }, [chest]); const equipped = usePartyStore.getState().members[0]; expect(equipped.maxHealth).toBe(beforeMaxHealth + (equipped.gearStats.stamina - target.gearStats.stamina) * 10); expect(equipped.gearStats.armor).toBeGreaterThanOrEqual(chest.stats.armor); expect(equipped.gearStats.strength).toBeGreaterThanOrEqual(chest.stats.strength); expect(usePartyStore.getState().damageMember(target.id, 100)).toBeLessThan(100); }); it("keeps a downed ally targetable for resurrection and clears it when removed", () => { usePartyStore.getState().initializeParty("selection-lifecycle", 10, 100); const selected = usePartyStore.getState().members[0]; expect(usePartyStore.getState().selectMember(selected.id)).toBe(true); const downedMembers = usePartyStore.getState().members.map((member) => member.id === selected.id ? { ...member, health: 0, status: "down" as const } : member); usePartyStore.getState().commitRuntime({ members: downedMembers, activeMobId: null, objectiveBossId: null, memberTargetIds: {}, }); expect(usePartyStore.getState().selectedMemberId).toBe(selected.id); usePartyStore.getState().commitRuntime({ members: downedMembers.filter((member) => member.id !== selected.id), activeMobId: null, objectiveBossId: null, memberTargetIds: {}, }); expect(usePartyStore.getState().selectedMemberId).toBeNull(); }); it("clears only the selected companion's dispellable control state", () => { usePartyStore.getState().initializeParty("control-cleanse", 10, 100); const [controlled, untouched] = usePartyStore.getState().members; expect(usePartyStore.getState().controlMember(controlled.id, "fear", 10_000)).toBe(true); expect(usePartyStore.getState().clearMemberControl(controlled.id)).toBe(true); expect(usePartyStore.getState().members.find((member) => member.id === controlled.id)).toMatchObject({ controlledUntil: 0, controlMechanic: null, status: "ready", }); expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.controlledUntil).toBe(0); }); });