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(); | |