export interface VirtualFile { path: string; content: string; lastModified: Date; version?: number; } /** * Simple Virtual File System for game content * Treats editor content as a single virtual HTML file */ export class VirtualFileSystem { private static instance: VirtualFileSystem | null = null; private gameFile: VirtualFile; private editHistory: Array<{ timestamp: Date; oldText: string; newText: string; version: number; }> = []; public static readonly GAME_FILE_PATH = "/game.html"; private constructor() { this.gameFile = { path: VirtualFileSystem.GAME_FILE_PATH, content: "", lastModified: new Date(), version: 0, }; } static getInstance(): VirtualFileSystem { if (!VirtualFileSystem.instance) { VirtualFileSystem.instance = new VirtualFileSystem(); } return VirtualFileSystem.instance; } readFile(path: string): VirtualFile | null { if (path === VirtualFileSystem.GAME_FILE_PATH) { return this.gameFile; } return null; } writeFile(path: string, content: string): void { if (path !== VirtualFileSystem.GAME_FILE_PATH) { throw new Error( `Only ${VirtualFileSystem.GAME_FILE_PATH} can be written to`, ); } const newVersion = (this.gameFile.version || 0) + 1; this.gameFile = { path, content, lastModified: new Date(), version: newVersion, }; } getGameFile(): VirtualFile { return this.gameFile; } updateGameContent(content: string): void { this.writeFile(VirtualFileSystem.GAME_FILE_PATH, content); } searchContent( query: string, mode: "text" | "regex" = "text", ): Array<{ path: string; lineNumber: number; line: string; context: string[]; }> { const results: Array<{ path: string; lineNumber: number; line: string; context: string[]; }> = []; const lines = this.gameFile.content.split("\n"); for (let i = 0; i < lines.length; i++) { let isMatch = false; if (mode === "text") { isMatch = lines[i].includes(query); } else if (mode === "regex") { try { const regex = new RegExp(query); isMatch = regex.test(lines[i]); } catch { continue; } } if (isMatch) { const contextLines = 2; const startContext = Math.max(0, i - contextLines); const endContext = Math.min(lines.length - 1, i + contextLines); const context: string[] = []; for (let j = startContext; j <= endContext; j++) { const lineNum = j + 1; const prefix = j === i ? ">>> " : " "; context.push(`${prefix}${lineNum}: ${lines[j]}`); } results.push({ path: VirtualFileSystem.GAME_FILE_PATH, lineNumber: i + 1, line: lines[i], context, }); } } return results; } /** * Normalize text for flexible matching while preserving exact replacement */ private normalizeForMatching(text: string): string { // Normalize line endings and trim each line return text .split(/\r?\n/) .map((line) => line.trimEnd()) .join("\n") .trim(); } /** * Find all positions where normalized text matches */ private findNormalizedMatches( content: string, searchText: string, ): Array<{ start: number; end: number; text: string }> { const matches: Array<{ start: number; end: number; text: string }> = []; const normalizedSearch = this.normalizeForMatching(searchText); const lines = content.split(/\r?\n/); // Try to find matches line by line with flexible whitespace for (let startLine = 0; startLine < lines.length; startLine++) { // Build potential match starting from this line for ( let endLine = startLine; endLine < lines.length && endLine < startLine + 100; endLine++ ) { const candidateLines = lines.slice(startLine, endLine + 1); const candidateText = candidateLines.join("\n"); const normalizedCandidate = this.normalizeForMatching(candidateText); if (normalizedCandidate === normalizedSearch) { // Found a match! Calculate actual positions in original content let position = 0; for (let i = 0; i < startLine; i++) { position += lines[i].length + 1; // +1 for newline } const start = position; const end = start + candidateText.length; matches.push({ start, end, text: candidateText, }); break; // Don't look for longer matches starting from same line } } } return matches; } /** * Get context lines around a position in content */ private getContextAtPosition( content: string, position: number, contextLines: number = 3, ): string { const lines = content.split(/\r?\n/); let currentPos = 0; let targetLine = 0; // Find which line contains the position for (let i = 0; i < lines.length; i++) { if (currentPos + lines[i].length >= position) { targetLine = i; break; } currentPos += lines[i].length + 1; } const startLine = Math.max(0, targetLine - contextLines); const endLine = Math.min(lines.length - 1, targetLine + contextLines); const contextParts: string[] = []; for (let i = startLine; i <= endLine; i++) { const lineNum = i + 1; const prefix = i === targetLine ? ">>> " : " "; contextParts.push(`${prefix}${lineNum}: ${lines[i]}`); } return contextParts.join("\n"); } editContent( oldText: string, newText: string, ): { success: boolean; error?: string; version?: number } { const currentVersion = this.gameFile.version || 0; if (this.gameFile.content.includes(oldText)) { const occurrences = this.gameFile.content.split(oldText).length - 1; if (occurrences === 1) { const newContent = this.gameFile.content.replace(oldText, newText); this.editHistory.push({ timestamp: new Date(), oldText, newText, version: currentVersion, }); if (this.editHistory.length > 20) { this.editHistory = this.editHistory.slice(-20); } this.updateGameContent(newContent); return { success: true, version: this.gameFile.version }; } else if (occurrences > 1) { return { success: false, error: `Found ${occurrences} exact occurrences of the text. Be more specific.`, }; } } const matches = this.findNormalizedMatches(this.gameFile.content, oldText); if (matches.length === 0) { // Show context to help understand why match failed const shortPreview = oldText.substring(0, 50) + (oldText.length > 50 ? "..." : ""); const searchResults = this.searchContent( oldText.split(/\r?\n/)[0].trim(), "text", ); let errorMsg = `Could not find the specified text to replace: "${shortPreview}"`; if (searchResults.length > 0) { errorMsg += "\n\nDid you mean one of these locations?\n"; searchResults.slice(0, 3).forEach((result) => { errorMsg += "\n" + result.context.join("\n") + "\n"; }); } return { success: false, error: errorMsg, }; } if (matches.length > 1) { let errorMsg = `Found ${matches.length} matches with normalized whitespace. Please be more specific.\n\nMatches found at:`; matches.slice(0, 3).forEach((match, i) => { errorMsg += `\n\nMatch ${i + 1}:\n`; errorMsg += this.getContextAtPosition( this.gameFile.content, match.start, ); }); return { success: false, error: errorMsg, }; } // Exactly one match found - replace it const match = matches[0]; const newContent = this.gameFile.content.substring(0, match.start) + newText + this.gameFile.content.substring(match.end); this.editHistory.push({ timestamp: new Date(), oldText, newText, version: currentVersion, }); if (this.editHistory.length > 20) { this.editHistory = this.editHistory.slice(-20); } this.updateGameContent(newContent); return { success: true, version: this.gameFile.version }; } /** * Get recent edit history for debugging */ getEditHistory(): Array<{ timestamp: Date; oldText: string; newText: string; version: number; }> { return [...this.editHistory]; } getLines( startLine: number, endLine?: number, ): { content: string; error?: string } { const lines = this.gameFile.content.split("\n"); const totalLines = lines.length; if (startLine > totalLines) { return { content: "", error: `Start line ${startLine} exceeds total lines (${totalLines})`, }; } const actualEndLine = endLine || startLine; if (actualEndLine > totalLines) { return { content: "", error: `End line ${actualEndLine} exceeds total lines (${totalLines})`, }; } if (startLine > actualEndLine) { return { content: "", error: `Start line (${startLine}) cannot be greater than end line (${actualEndLine})`, }; } const selectedLines = lines.slice(startLine - 1, actualEndLine); const lineNumbers: number[] = []; for (let i = startLine; i <= actualEndLine; i++) { lineNumbers.push(i); } const result = selectedLines .map((line, index) => `${lineNumbers[index]}: ${line}`) .join("\n"); return { content: `Lines ${startLine}-${actualEndLine} of ${totalLines}:\n${result}`, }; } } export const virtualFileSystem = VirtualFileSystem.getInstance();