VibeGame / src /lib /services /virtual-fs.ts
dylanebert
improved prompting/UX
db9635c
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();