✨Feature: implement game shell UI with room navigation and combat mechanics
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user