From d504008030c1ba6f160a154df8761165e6985b45 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:50:50 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20run=20state=20m?= =?UTF-8?q?anagement=20with=20combat=20resolution=20and=20room=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/runState.test.ts | 186 ++++++++++++++++++++ src/rules/runState.ts | 335 +++++++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 src/rules/runState.test.ts create mode 100644 src/rules/runState.ts diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts new file mode 100644 index 0000000..31a3264 --- /dev/null +++ b/src/rules/runState.test.ts @@ -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, + ); + }); +}); diff --git a/src/rules/runState.ts b/src/rules/runState.ts new file mode 100644 index 0000000..29da125 --- /dev/null +++ b/src/rules/runState.ts @@ -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, + }; +}