import { beforeEach, describe, expect, it } from "vitest"; import { APPEARANCE_KEYS, classesForRace, raceById, supportedGenders, } from "../app/characterCatalog"; import { createDungeonParty, isHealerSpecialization, isTankSpecialization, usePartyStore, } from "./partyStore"; import { spentTalentPoints, talentPointsForLevel, talentTreeById } from "./talentCatalog"; import type { InventoryItem } from "./lootTypes"; import { EMPTY_ITEM_STATS } from "./itemStats"; 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("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("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("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); }); });