154 lines
6.8 KiB
TypeScript
154 lines
6.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
MAX_ACTIVE_ROSTER,
|
|
PARTY_RECRUITS_PER_WAVE,
|
|
createRpgRoguelikeRun,
|
|
createRunGearItem,
|
|
moveRpgFocus,
|
|
partyCompositionLabel,
|
|
partyRolePresentation,
|
|
reduceRpgRoguelikeRun,
|
|
rpgFocusId,
|
|
rpgFocusItems,
|
|
type RpgRoguelikeRunState,
|
|
} from ".";
|
|
|
|
function reachSpellDraft(seed = 810): RpgRoguelikeRunState {
|
|
let state = createRpgRoguelikeRun({ seed });
|
|
while (state.phase === "party-draft") {
|
|
const draft = state.partyDraft!;
|
|
const tank = draft.offers.find((offer) => offer.role === "Tank");
|
|
const ordered = tank ? [tank, ...draft.offers.filter((offer) => offer !== tank)] : draft.offers;
|
|
for (const offer of ordered) {
|
|
if (state.roster.length >= MAX_ACTIVE_ROSTER || state.partyDraft!.recruitedThisWaveIds.length >= PARTY_RECRUITS_PER_WAVE) break;
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offer.candidateId });
|
|
}
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" });
|
|
}
|
|
return state;
|
|
}
|
|
|
|
describe("RPG Roguelike semantic UI focus", () => {
|
|
it("presents party roles as Tank/DPS and summarizes duplicate tanks", () => {
|
|
expect(partyRolePresentation("Tank")).toEqual({ label: "Tank", icon: "⬡", className: "tank" });
|
|
expect(partyRolePresentation("Damage")).toEqual({ label: "DPS", icon: "⚔", className: "damage" });
|
|
expect(partyCompositionLabel([
|
|
{ role: "Tank" },
|
|
{ role: "Tank" },
|
|
{ role: "Damage" },
|
|
{ role: "Damage" },
|
|
])).toBe("2 Tanks · 2 DPS");
|
|
});
|
|
|
|
it("includes each party role in controller action labels", () => {
|
|
const state = createRpgRoguelikeRun({ seed: 19 });
|
|
const labels = new Map(rpgFocusItems(state).map((item) => [item.id, item.label]));
|
|
for (const candidate of state.partyDraft!.offers) {
|
|
expect(labels.get(rpgFocusId.partyOffer(candidate.candidateId))).toBe(
|
|
`Recruit ${candidate.name}, ${partyRolePresentation(candidate.role).label}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("moves horizontally within card rows and vertically between action groups", () => {
|
|
let state = createRpgRoguelikeRun({ seed: 120 });
|
|
const offers = state.partyDraft!.offers;
|
|
const first = rpgFocusId.partyOffer(offers[0].candidateId);
|
|
const second = rpgFocusId.partyOffer(offers[1].candidateId);
|
|
const last = rpgFocusId.partyOffer(offers[offers.length - 1].candidateId);
|
|
expect(moveRpgFocus(state, first, "right")).toBe(second);
|
|
expect(moveRpgFocus(state, first, "left")).toBe(last);
|
|
expect(moveRpgFocus(state, second, "down")).toBe(rpgFocusId.partyContinue);
|
|
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offers[0].candidateId });
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" });
|
|
const laterOffer = rpgFocusId.partyOffer(state.partyDraft!.offers[0].candidateId);
|
|
const rosterAction = rpgFocusId.partyMember(offers[0].candidateId);
|
|
expect(moveRpgFocus(state, laterOffer, "down")).toBe(rosterAction);
|
|
expect(moveRpgFocus(state, rosterAction, "down")).toBe(rpgFocusId.partyContinue);
|
|
});
|
|
|
|
it("orders party offers, later-wave roster removals, then Continue", () => {
|
|
let state = createRpgRoguelikeRun({ seed: 12 });
|
|
const firstOffer = state.partyDraft!.offers[0];
|
|
expect(rpgFocusItems(state).map((item) => item.id)).toEqual([
|
|
...state.partyDraft!.offers.map((offer) => rpgFocusId.partyOffer(offer.candidateId)),
|
|
rpgFocusId.partyContinue,
|
|
]);
|
|
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: firstOffer.candidateId });
|
|
expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.partyMember(firstOffer.candidateId));
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" });
|
|
expect(rpgFocusItems(state).map((item) => item.id)).toEqual([
|
|
...state.partyDraft!.offers.map((offer) => rpgFocusId.partyOffer(offer.candidateId)),
|
|
rpgFocusId.partyMember(firstOffer.candidateId),
|
|
rpgFocusId.partyContinue,
|
|
]);
|
|
});
|
|
|
|
it("omits final-wave removals that cannot be refilled", () => {
|
|
let state = createRpgRoguelikeRun({ seed: 91 });
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" });
|
|
const prior = state.partyDraft!.offers.find((offer) => offer.role === "Tank")!;
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: prior.candidateId });
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-next-wave" });
|
|
for (const offer of state.partyDraft!.offers.slice(0, 3)) {
|
|
state = reduceRpgRoguelikeRun(state, { type: "party-recruit", candidateId: offer.candidateId });
|
|
}
|
|
|
|
expect(state.roster).toHaveLength(4);
|
|
expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.partyMember(prior.candidateId));
|
|
expect(reduceRpgRoguelikeRun(state, { type: "party-remove", memberId: prior.candidateId })).toBe(state);
|
|
});
|
|
|
|
it("locks first-wave spell picks, then exposes selected removals before Continue", () => {
|
|
let state = reachSpellDraft();
|
|
const spellId = state.spellDraft!.offers[0];
|
|
state = reduceRpgRoguelikeRun(state, { type: "spell-pick", spellId });
|
|
expect(rpgFocusItems(state).map((item) => item.id)).not.toContain(rpgFocusId.spellSelected(spellId));
|
|
|
|
state = reduceRpgRoguelikeRun(state, { type: "spell-next-wave" });
|
|
expect(rpgFocusItems(state).map((item) => item.id)).toEqual([
|
|
...state.spellDraft!.offers.map((offer) => rpgFocusId.spellOffer(offer)),
|
|
rpgFocusId.spellSelected(spellId),
|
|
rpgFocusId.spellContinue,
|
|
]);
|
|
});
|
|
|
|
it("orders shop purchases, bag sales, Rest, Revive, then Leave and omits disabled actions", () => {
|
|
const drafted = reachSpellDraft(44);
|
|
const offerItem = createRunGearItem("shop-weapon", "player", "weapon", 1);
|
|
const soldItem = createRunGearItem("sold-armor", "player", "armor", 1);
|
|
const bagItem = createRunGearItem("bag-trinket", "player", "trinket", 0);
|
|
const first = drafted.roster[0];
|
|
const second = drafted.roster[1];
|
|
const state: RpgRoguelikeRunState = {
|
|
...drafted,
|
|
phase: "shop",
|
|
currency: 200,
|
|
roster: drafted.roster.map((member) => member.instanceId === first.instanceId
|
|
? { ...member, hp: 0 }
|
|
: member.instanceId === second.instanceId
|
|
? { ...member, hp: 1 }
|
|
: member),
|
|
bag: [bagItem],
|
|
shop: {
|
|
act: 1,
|
|
offers: [
|
|
{ id: "available", item: offerItem, price: 80, sold: false },
|
|
{ id: "sold", item: soldItem, price: 20, sold: true },
|
|
],
|
|
restCost: 35,
|
|
reviveCost: 60,
|
|
},
|
|
};
|
|
expect(rpgFocusItems(state).map((item) => item.id)).toEqual([
|
|
rpgFocusId.shopOffer("available"),
|
|
rpgFocusId.shopSell(bagItem.id),
|
|
rpgFocusId.shopRest,
|
|
rpgFocusId.shopRevive(first.instanceId),
|
|
rpgFocusId.shopLeave,
|
|
]);
|
|
});
|
|
});
|