Spaces:
Running
Running
| 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(); | |