Feature: implement game shell UI with room navigation and combat mechanics

This commit is contained in:
Keith Solomon
2026-03-15 14:02:19 -05:00
parent d504008030
commit fb6cbfe9fb
4 changed files with 804 additions and 117 deletions

View File

@@ -6,9 +6,11 @@ import { createStartingAdventurer } from "./character";
import {
createRunState,
enterCurrentRoom,
getAvailableMoves,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
startCombatInCurrentRoom,
travelCurrentExit,
} from "./runState";
function createSequenceRoller(values: number[]) {
@@ -183,4 +185,46 @@ describe("run state flow", () => {
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");
});
});

View File

@@ -14,7 +14,11 @@ import {
type ResolveEnemyTurnOptions,
type ResolvePlayerAttackOptions,
} from "./combatTurns";
import { initializeDungeonLevel } from "./dungeon";
import {
expandLevelFromExit,
getUnresolvedExits,
initializeDungeonLevel,
} from "./dungeon";
import type { DiceRoller } from "./dice";
import { enterRoom } from "./roomEntry";
@@ -55,6 +59,23 @@ export type ResolveRunEnemyTurnOptions = {
at?: string;
};
export type TravelCurrentExitOptions = {
content: ContentPack;
run: RunState;
exitDirection: "north" | "east" | "south" | "west";
roomTableCode?: string;
roller?: DiceRoller;
at?: string;
};
export type AvailableMove = {
direction: "north" | "east" | "south" | "west";
exitType: string;
discovered: boolean;
leadsToRoomId?: string;
generated: boolean;
};
export type RunTransitionResult = {
run: RunState;
logEntries: LogEntry[];
@@ -167,6 +188,37 @@ function requireCurrentRoomId(run: RunState) {
return run.currentRoomId;
}
function requireCurrentRoom(run: RunState) {
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
if (!room) {
throw new Error(`Unknown room id: ${roomId}`);
}
return room;
}
function inferNextRoomTableCode(run: RunState) {
const room = requireCurrentRoom(run);
const levelState = requireCurrentLevel(run);
if (room.roomClass === "start") {
return "L1LR";
}
if (room.roomClass === "small") {
return "L1LR";
}
if (room.roomClass === "large") {
return "L1SR";
}
return levelState.discoveredRoomOrder.length % 2 === 0 ? "L1LR" : "L1SR";
}
function syncPlayerToAdventurer(run: RunState) {
if (!run.activeCombat) {
return;
@@ -232,6 +284,96 @@ export function enterCurrentRoom(
};
}
export function getAvailableMoves(run: RunState): AvailableMove[] {
const room = requireCurrentRoom(run);
return room.exits
.filter((exit) => exit.traversable)
.map((exit) => ({
direction: exit.direction,
exitType: exit.exitType,
discovered: exit.discovered,
leadsToRoomId: exit.leadsToRoomId,
generated: Boolean(exit.leadsToRoomId),
}));
}
export function travelCurrentExit(
options: TravelCurrentExitOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.activeCombat) {
throw new Error("Cannot travel while combat is active.");
}
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = requireCurrentRoom(run);
const exit = room.exits.find((candidate) => candidate.direction === options.exitDirection);
if (!exit) {
throw new Error(`Current room does not have an exit to the ${options.exitDirection}.`);
}
if (!exit.traversable) {
throw new Error(`Exit ${exit.id} is not traversable.`);
}
let nextLevelState = levelState;
let destinationRoomId = exit.leadsToRoomId;
const at = options.at ?? new Date().toISOString();
if (!destinationRoomId) {
const unresolvedExits = getUnresolvedExits(levelState);
const matchingExit = unresolvedExits.find(
(candidate) =>
candidate.roomId === roomId && candidate.direction === options.exitDirection,
);
if (!matchingExit) {
throw new Error(`Exit ${exit.id} is no longer available for generation.`);
}
const expansion = expandLevelFromExit({
content: options.content,
levelState,
fromRoomId: roomId,
exitDirection: options.exitDirection,
roomTableCode: options.roomTableCode ?? inferNextRoomTableCode(run),
roller: options.roller,
});
nextLevelState = expansion.levelState;
destinationRoomId = expansion.createdRoom.id;
}
run.dungeon.levels[run.currentLevel] = nextLevelState;
run.currentRoomId = destinationRoomId;
const movedLog: LogEntry = {
id: `${roomId}.travel.${options.exitDirection}.${run.log.length + 1}`,
at,
type: "room",
text: `Travelled ${options.exitDirection} from ${room.id} to ${destinationRoomId}.`,
relatedIds: [room.id, destinationRoomId],
};
appendLogs(run, [movedLog]);
const entered = enterCurrentRoom({
content: options.content,
run,
roller: options.roller,
at,
});
return {
run: entered.run,
logEntries: [movedLog, ...entered.logEntries],
};
}
export function startCombatInCurrentRoom(
options: StartCurrentCombatOptions,
): RunTransitionResult {