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, ]); }); });