Files
2D6-Dungeon/src/rules/runState.test.ts

231 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import {
createRunState,
enterCurrentRoom,
getAvailableMoves,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
startCombatInCurrentRoom,
travelCurrentExit,
} from "./runState";
function createSequenceRoller(values: number[]) {
let index = 0;
return () => {
const next = values[index];
index += 1;
return next;
};
}
function createAdventurer() {
return createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
armourId: "armour.leather-vest",
scrollId: "scroll.lesser-heal",
});
}
describe("run state flow", () => {
it("creates a run anchored in the start room", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
expect(run.currentLevel).toBe(1);
expect(run.currentRoomId).toBe("room.level1.start");
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
});
it("enters the current room and appends room logs", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const result = enterCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-15T14:01:00.000Z",
});
expect(result.run.log).toHaveLength(1);
expect(result.run.log[0]?.text).toContain("Re-entered Entry Chamber");
});
it("starts combat from the current room and stores the active combat state", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const levelState = run.dungeon.levels["1"]!;
const room = levelState.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a", "b"],
creatureNames: ["Guard", "Warrior"],
resultLabel: "Guard and Warrior",
resolved: true,
};
const result = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-15T14:02:00.000Z",
});
expect(result.run.activeCombat?.enemies).toHaveLength(2);
expect(result.run.log.at(-1)?.text).toContain("Combat begins");
});
it("resolves player and enemy turns through run state", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const levelState = run.dungeon.levels["1"]!;
const room = levelState.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a", "b"],
creatureNames: ["Guard", "Warrior"],
resultLabel: "Guard and Warrior",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-15T14:02:00.000Z",
}).run;
const targetEnemyId = withCombat.activeCombat!.enemies[0]!.id;
const afterPlayer = resolveRunPlayerTurn({
content: sampleContentPack,
run: withCombat,
manoeuvreId: "manoeuvre.exact-strike",
targetEnemyId,
roller: createSequenceRoller([6, 6]),
at: "2026-03-15T14:03:00.000Z",
}).run;
expect(afterPlayer.activeCombat?.actingSide).toBe("enemy");
expect(afterPlayer.log.at(-1)?.text).toContain("deals");
const afterEnemy = resolveRunEnemyTurn({
content: sampleContentPack,
run: afterPlayer,
roller: createSequenceRoller([6, 6]),
at: "2026-03-15T14:04:00.000Z",
}).run;
expect(afterEnemy.activeCombat?.actingSide).toBe("player");
expect(afterEnemy.adventurerSnapshot.hp.current).toBeLessThan(
createAdventurer().hp.current,
);
});
it("clears the room and ends active combat when the last enemy falls", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-15T14:02:00.000Z",
}).run;
withCombat.activeCombat!.enemies[0]!.hpCurrent = 1;
const result = resolveRunPlayerTurn({
content: sampleContentPack,
run: withCombat,
manoeuvreId: "manoeuvre.guard-break",
targetEnemyId: withCombat.activeCombat!.enemies[0]!.id,
roller: createSequenceRoller([6, 6]),
at: "2026-03-15T14:03:00.000Z",
});
expect(result.run.activeCombat).toBeUndefined();
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.discovery.cleared).toBe(true);
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.encounter?.rewardPending).toBe(
false,
);
});
it("lists available traversable exits for the current room", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
expect(getAvailableMoves(run)).toEqual([
expect.objectContaining({
direction: "north",
generated: false,
}),
]);
});
it("travels through an unresolved exit, generates a room, and enters it", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const result = travelCurrentExit({
content: sampleContentPack,
run,
exitDirection: "north",
roller: createSequenceRoller([1, 1]),
at: "2026-03-15T14:05:00.000Z",
});
expect(result.run.currentRoomId).toBe("room.level1.room.002");
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
"room.level1.start",
"room.level1.room.002",
]);
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
true,
);
expect(result.run.log[0]?.text).toContain("Travelled north");
});
});