new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
MAX_ACTIVE_ROSTER,
PARTY_RECRUITS_PER_WAVE,
createRpgRoguelikeRun,
createRunGearItem,
moveRpgFocus,
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("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,
]);
});
});