Feature: implement run state management with combat resolution and room entry

This commit is contained in:
Keith Solomon
2026-03-15 13:50:50 -05:00
parent 50873e6989
commit d504008030
2 changed files with 521 additions and 0 deletions

186
src/rules/runState.test.ts Normal file
View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import {
createRunState,
enterCurrentRoom,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
startCombatInCurrentRoom,
} 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,
);
});
});

335
src/rules/runState.ts Normal file
View File

@@ -0,0 +1,335 @@
import type { ContentPack } from "@/types/content";
import type {
AdventurerState,
CombatState,
DungeonState,
RunState,
} from "@/types/state";
import type { LogEntry } from "@/types/rules";
import { startCombatFromRoom } from "./combat";
import {
resolveEnemyTurn,
resolvePlayerAttack,
type ResolveEnemyTurnOptions,
type ResolvePlayerAttackOptions,
} from "./combatTurns";
import { initializeDungeonLevel } from "./dungeon";
import type { DiceRoller } from "./dice";
import { enterRoom } from "./roomEntry";
export type CreateRunOptions = {
content: ContentPack;
campaignId: string;
adventurer: AdventurerState;
runId?: string;
at?: string;
};
export type EnterCurrentRoomOptions = {
content: ContentPack;
run: RunState;
roller?: DiceRoller;
at?: string;
};
export type StartCurrentCombatOptions = {
content: ContentPack;
run: RunState;
at?: string;
};
export type ResolveRunPlayerTurnOptions = {
content: ContentPack;
run: RunState;
manoeuvreId: string;
targetEnemyId: string;
roller?: DiceRoller;
at?: string;
};
export type ResolveRunEnemyTurnOptions = {
content: ContentPack;
run: RunState;
roller?: DiceRoller;
at?: string;
};
export type RunTransitionResult = {
run: RunState;
logEntries: LogEntry[];
};
function cloneCombat(combat: CombatState): CombatState {
return {
...combat,
player: {
...combat.player,
statuses: [...combat.player.statuses],
traits: [...combat.player.traits],
},
enemies: combat.enemies.map((enemy) => ({
...enemy,
statuses: [...enemy.statuses],
traits: [...enemy.traits],
})),
combatLog: combat.combatLog.map((entry) => ({
...entry,
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
})),
};
}
function cloneRun(run: RunState): RunState {
return {
...run,
adventurerSnapshot: {
...run.adventurerSnapshot,
hp: { ...run.adventurerSnapshot.hp },
stats: { ...run.adventurerSnapshot.stats },
favour: { ...run.adventurerSnapshot.favour },
statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })),
inventory: {
...run.adventurerSnapshot.inventory,
carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })),
equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })),
stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })),
currency: { ...run.adventurerSnapshot.inventory.currency },
lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })),
},
progressionFlags: [...run.adventurerSnapshot.progressionFlags],
manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds],
},
dungeon: {
levels: Object.fromEntries(
Object.entries(run.dungeon.levels).map(([level, levelState]) => [
level,
{
...levelState,
rooms: Object.fromEntries(
Object.entries(levelState.rooms).map(([roomId, room]) => [
roomId,
{
...room,
position: { ...room.position },
dimensions: { ...room.dimensions },
exits: room.exits.map((exit) => ({ ...exit })),
discovery: { ...room.discovery },
encounter: room.encounter
? {
...room.encounter,
creatureIds: [...room.encounter.creatureIds],
creatureNames: room.encounter.creatureNames
? [...room.encounter.creatureNames]
: undefined,
}
: undefined,
objects: room.objects.map((object) => ({
...object,
effects: object.effects ? [...object.effects] : undefined,
})),
notes: [...room.notes],
flags: [...room.flags],
},
]),
),
discoveredRoomOrder: [...levelState.discoveredRoomOrder],
},
]),
) as DungeonState["levels"],
revealedPercentByLevel: { ...run.dungeon.revealedPercentByLevel },
globalFlags: [...run.dungeon.globalFlags],
},
activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined,
log: run.log.map((entry) => ({
...entry,
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
})),
pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })),
};
}
function requireCurrentLevel(run: RunState) {
const levelState = run.dungeon.levels[run.currentLevel];
if (!levelState) {
throw new Error(`Run is missing level ${run.currentLevel}.`);
}
return levelState;
}
function requireCurrentRoomId(run: RunState) {
if (!run.currentRoomId) {
throw new Error("Run does not have a current room.");
}
return run.currentRoomId;
}
function syncPlayerToAdventurer(run: RunState) {
if (!run.activeCombat) {
return;
}
run.adventurerSnapshot.hp.current = run.activeCombat.player.hpCurrent;
run.adventurerSnapshot.statuses = run.activeCombat.player.statuses.map((status) => ({ ...status }));
}
function appendLogs(run: RunState, logEntries: LogEntry[]) {
run.log.push(...logEntries);
}
export function createRunState(options: CreateRunOptions): RunState {
const at = options.at ?? new Date().toISOString();
const levelState = initializeDungeonLevel({
content: options.content,
level: 1,
});
return {
id: options.runId ?? "run.active",
campaignId: options.campaignId,
status: "active",
startedAt: at,
currentLevel: 1,
currentRoomId: "room.level1.start",
dungeon: {
levels: {
1: levelState,
},
revealedPercentByLevel: {
1: 0,
},
globalFlags: [],
},
adventurerSnapshot: options.adventurer,
log: [],
pendingEffects: [],
};
}
export function enterCurrentRoom(
options: EnterCurrentRoomOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const entry = enterRoom({
content: options.content,
levelState,
roomId,
roller: options.roller,
at: options.at,
});
run.dungeon.levels[run.currentLevel] = entry.levelState;
appendLogs(run, entry.logEntries);
return {
run,
logEntries: entry.logEntries,
};
}
export function startCombatInCurrentRoom(
options: StartCurrentCombatOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
if (!room) {
throw new Error(`Unknown room id: ${roomId}`);
}
const started = startCombatFromRoom({
content: options.content,
adventurer: run.adventurerSnapshot,
room,
at: options.at,
});
levelState.rooms[roomId] = started.room;
run.activeCombat = started.combat;
appendLogs(run, started.logEntries);
return {
run,
logEntries: started.logEntries,
};
}
export function resolveRunPlayerTurn(
options: ResolveRunPlayerTurnOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}
const result = resolvePlayerAttack({
content: options.content,
combat: run.activeCombat,
adventurer: run.adventurerSnapshot,
manoeuvreId: options.manoeuvreId,
targetEnemyId: options.targetEnemyId,
roller: options.roller,
at: options.at,
} satisfies ResolvePlayerAttackOptions);
run.activeCombat = result.combat;
syncPlayerToAdventurer(run);
appendLogs(run, result.logEntries);
if (result.combatEnded) {
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
if (room?.encounter) {
room.encounter.rewardPending = false;
room.discovery.cleared = true;
}
run.activeCombat = undefined;
}
return {
run,
logEntries: result.logEntries,
};
}
export function resolveRunEnemyTurn(
options: ResolveRunEnemyTurnOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}
const result = resolveEnemyTurn({
content: options.content,
combat: run.activeCombat,
adventurer: run.adventurerSnapshot,
roller: options.roller,
at: options.at,
} satisfies ResolveEnemyTurnOptions);
run.activeCombat = result.combat;
syncPlayerToAdventurer(run);
appendLogs(run, result.logEntries);
if (result.combatEnded) {
run.status = "failed";
}
return {
run,
logEntries: result.logEntries,
};
}