import type { ContentPack } from "@/types/content"; import type { DungeonLevelState, RoomExitState, RoomState } from "@/types/state"; import { createLevelShell, generateLevel1StartRoom, generateRoomFromTable } from "./rooms"; import type { DiceRoller } from "./dice"; type CardinalDirection = "north" | "east" | "south" | "west"; export type InitializeLevelOptions = { content: ContentPack; level?: number; startRoomId?: string; }; export type ExpandFromExitOptions = { content: ContentPack; levelState: DungeonLevelState; fromRoomId: string; exitDirection: CardinalDirection; roomTableCode: string; roomId?: string; roller?: DiceRoller; }; export type ExpansionResult = { levelState: DungeonLevelState; createdRoom: RoomState; fromRoom: RoomState; }; const DIRECTION_VECTORS: Record = { north: { x: 0, y: -1 }, east: { x: 1, y: 0 }, south: { x: 0, y: 1 }, west: { x: -1, y: 0 }, }; const OPPOSITE_DIRECTION: Record = { north: "south", east: "west", south: "north", west: "east", }; function cloneRoom(room: RoomState): RoomState { return { ...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], }; } function cloneLevel(levelState: DungeonLevelState): DungeonLevelState { return { ...levelState, rooms: Object.fromEntries( Object.entries(levelState.rooms).map(([roomId, room]) => [roomId, cloneRoom(room)]), ), discoveredRoomOrder: [...levelState.discoveredRoomOrder], }; } function findExit(room: RoomState, direction: CardinalDirection): RoomExitState { const exit = room.exits.find((candidate) => candidate.direction === direction); if (!exit) { throw new Error(`Room ${room.id} does not have an exit facing ${direction}.`); } return exit; } function computeNextPosition(room: RoomState, direction: CardinalDirection) { const offset = DIRECTION_VECTORS[direction]; return { x: room.position.x + offset.x, y: room.position.y + offset.y, }; } function connectRooms( fromRoom: RoomState, toRoom: RoomState, direction: CardinalDirection, ) { const fromExit = findExit(fromRoom, direction); fromExit.leadsToRoomId = toRoom.id; fromExit.discovered = true; const oppositeDirection = OPPOSITE_DIRECTION[direction]; const returnExit = toRoom.exits.find((candidate) => candidate.direction === oppositeDirection) ?? toRoom.exits[0]; if (!returnExit) { throw new Error(`Generated room ${toRoom.id} does not have an available return exit.`); } returnExit.direction = oppositeDirection; returnExit.leadsToRoomId = fromRoom.id; returnExit.discovered = true; } function getNextRoomId(levelState: DungeonLevelState, roomClass: string) { const sequence = levelState.discoveredRoomOrder.length + 1; return `room.level${levelState.level}.${roomClass}.${String(sequence).padStart(3, "0")}`; } function assertCoordinateAvailable(levelState: DungeonLevelState, position: { x: number; y: number }) { const occupiedRoom = Object.values(levelState.rooms).find( (room) => room.position.x === position.x && room.position.y === position.y, ); if (occupiedRoom) { throw new Error( `Cannot place a new room at (${position.x}, ${position.y}); ${occupiedRoom.id} already occupies that position.`, ); } } export function initializeDungeonLevel( options: InitializeLevelOptions, ): DungeonLevelState { const level = options.level ?? 1; if (level !== 1) { throw new Error("Only Level 1 initialization is currently implemented."); } const levelState = createLevelShell(level); const startResult = generateLevel1StartRoom( options.content, options.startRoomId ?? `room.level${level}.start`, ); levelState.rooms[startResult.room.id] = startResult.room; levelState.discoveredRoomOrder.push(startResult.room.id); return levelState; } export function getUnresolvedExits(levelState: DungeonLevelState) { return levelState.discoveredRoomOrder.flatMap((roomId) => { const room = levelState.rooms[roomId]; return room.exits .filter((exit) => !exit.leadsToRoomId && exit.traversable) .map((exit) => ({ roomId, exitId: exit.id, direction: exit.direction, exitType: exit.exitType, })); }); } export function expandLevelFromExit( options: ExpandFromExitOptions, ): ExpansionResult { const levelState = cloneLevel(options.levelState); const fromRoom = levelState.rooms[options.fromRoomId]; if (!fromRoom) { throw new Error(`Unknown source room id: ${options.fromRoomId}`); } const sourceExit = findExit(fromRoom, options.exitDirection); if (sourceExit.leadsToRoomId) { throw new Error(`Exit ${sourceExit.id} is already connected to ${sourceExit.leadsToRoomId}.`); } if (!sourceExit.traversable) { throw new Error(`Exit ${sourceExit.id} is not traversable.`); } const position = computeNextPosition(fromRoom, options.exitDirection); assertCoordinateAvailable(levelState, position); const provisionalRoomId = options.roomId ?? getNextRoomId(levelState, options.roomTableCode === "L1SR" ? "small" : "room"); const result = generateRoomFromTable({ content: options.content, roomId: provisionalRoomId, level: levelState.level, roomTableCode: options.roomTableCode, position, roller: options.roller, }); connectRooms(fromRoom, result.room, options.exitDirection); levelState.rooms[fromRoom.id] = fromRoom; levelState.rooms[result.room.id] = result.room; levelState.discoveredRoomOrder.push(result.room.id); return { levelState, createdRoom: result.room, fromRoom, }; }