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

375 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
APPEARANCE_KEYS,
ROM_CLASS_IDS,
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("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("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<string>();
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<string>();
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);
});
});