314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
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<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;
|
|
|
|
expect(usePartyStore.getState().damageMember(target.id, 25)).toBe(25);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health)
|
|
.toBe(target.maxHealth - 25);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === untouched.id)?.health)
|
|
.toBe(untouched.maxHealth);
|
|
expect(usePartyStore.getState().damageMember(target.id, target.maxHealth)).toBe(target.maxHealth - 25);
|
|
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 + 40);
|
|
expect(equipped.gearStats).toEqual(chest.stats);
|
|
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);
|
|
});
|
|
});
|