Checkpoint current interactive fiction state

This commit is contained in:
2026-05-14 21:17:43 +02:00
parent c745efd1d2
commit 873049f7e6
183 changed files with 13755 additions and 1459 deletions
+140 -62
View File
@@ -24,36 +24,120 @@ const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express.static(path.join(__dirname, '../public')));
// Serve static files from the public directory. During local development the
// browser must not keep stale ES modules, otherwise UI fixes appear to do
// nothing until a hard cache clear.
app.use(express.static(path.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
// Set up game sessions
const gameSessions = new Map<string, GameRunner>();
const gameSessions = new Map<string, GameRunner>();
function normalizeSaveSlot(slot: unknown): number {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
}
async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
const gameRunner = new GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
await gameRunner.initialize(worldFile);
gameSessions.set(socket.id, gameRunner);
const gameState = gameRunner.getGameState();
socket.emit('gameIntroduction', {
introduction: gameState.world.introduction,
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameState.currentRoomId
});
return gameRunner;
}
async function handleGameApi(socket: any, method: string, args: unknown[] = []) {
const saveGames: Map<number, any> = socket.data.saveGames || new Map<number, any>();
socket.data.saveGames = saveGames;
switch (method) {
case 'newGame':
case 'newGame()':
await startDemoGameForSocket(socket);
return { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!saveGames.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
await startDemoGameForSocket(socket);
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
case 'saveGame()': {
const gameRunner = gameSessions.get(socket.id);
if (!gameRunner) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
saveGames.set(slot, gameRunner.getGameState());
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: saveGames.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return { success: true, result: Array.from(saveGames.keys()).sort((a, b) => a - b) };
case 'isGameRunning':
case 'isGameRunning()':
return { success: true, result: gameSessions.has(socket.id) };
default:
return { success: false, error: `unknown_method:${method}` };
}
}
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
// Start a new game
socket.on('startGame', async () => {
try {
// Initialize game runner
const gameRunner = new GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Initialize the game
await gameRunner.initialize(worldFile);
// Store game session
gameSessions.set(socket.id, gameRunner);
// Send introduction to client
const gameState = gameRunner.getGameState();
socket.emit('gameIntroduction', {
introduction: gameState.world.introduction,
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameState.currentRoomId
});
console.log(`New client connected: ${socket.id}`);
socket.data.saveGames = new Map<number, any>();
socket.on('gameApi', async (request, respond) => {
try {
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : []);
if (typeof respond === 'function') {
respond(response);
}
} catch (error) {
console.error('Game API error:', error);
if (typeof respond === 'function') {
respond({ success: false, error: error instanceof Error ? error.message : String(error) });
}
}
});
// Start a new game
socket.on('startGame', async () => {
try {
await handleGameApi(socket, 'newGame', []);
} catch (error) {
console.error('Error starting game:', error);
@@ -66,20 +150,20 @@ io.on('connection', (socket) => {
try {
const gameRunner = gameSessions.get(socket.id);
if (!gameRunner) {
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
return;
}
// Process command and get response
const response = await gameRunner.processCommand(data.command);
// Send narrative response to client
socket.emit('narrativeResponse', {
text: response,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
if (!gameRunner) {
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
return;
}
const command = String(data?.command || '').trim();
// During typography and animation work, mirror the command back through
// the real socket path so the UI pipeline can be tested end to end.
socket.emit('narrativeResponse', {
text: command,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
suggestions: gameRunner.getSuggestions()
});
@@ -99,8 +183,7 @@ io.on('connection', (socket) => {
return;
}
// Store save data in session
socket.data.savedGame = gameRunner.getGameState();
socket.data.saveGames.set(1, gameRunner.getGameState());
socket.emit('gameSaved');
@@ -111,7 +194,7 @@ io.on('connection', (socket) => {
});
// Load game state
socket.on('loadGame', () => {
socket.on('loadGame', async () => {
try {
const gameRunner = gameSessions.get(socket.id);
@@ -120,20 +203,13 @@ io.on('connection', (socket) => {
return;
}
// Check if there's a saved game
if (!socket.data.savedGame) {
socket.emit('error', { message: 'No saved game found.' });
return;
}
// Load saved game
gameRunner.loadGameState(socket.data.savedGame);
// Send current state to client
socket.emit('gameLoaded', {
currentRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameRunner.getGameState().currentRoomId
});
// Check if there's a saved game
if (!socket.data.saveGames?.has(1)) {
socket.emit('error', { message: 'No saved game found.' });
return;
}
await handleGameApi(socket, 'loadGame', [1]);
} catch (error) {
console.error('Error loading game:', error);
@@ -156,11 +232,13 @@ io.on('connection', (socket) => {
function ensureDirectories() {
const dirs = [
path.join(__dirname, '../public'),
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/fonts')
];
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/music'),
path.join(__dirname, '../public/sounds'),
path.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
if (!existsSync(dir)) {
@@ -243,4 +321,4 @@ if (require.main === module) {
});
}
export { app, server, io };
export { app, server, io };
+94 -13
View File
@@ -23,8 +23,17 @@ const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express.static(path.join(__dirname, '../public')));
// Serve static files from the public directory. Keep browser modules uncached
// during local development so fixes are visible without a hard cache clear.
app.use(express.static(path.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
// Test paragraphs to send to the client
const TEST_PARAGRAPHS = [
@@ -37,18 +46,91 @@ const TEST_PARAGRAPHS = [
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
let gameRunning = false;
const saveGames = new Set<number>();
const startDemoGame = () => {
gameRunning = true;
currentParagraphIndex = 0;
socket.emit('gameIntroduction', {
introduction: "::chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
});
};
const normalizeSaveSlot = (slot: unknown): number => {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
};
socket.on('gameApi', (request, respond) => {
try {
const method = String(request?.method || '');
const args = Array.isArray(request?.args) ? request.args : [];
let response: any;
switch (method) {
case 'newGame':
case 'newGame()':
startDemoGame();
response = { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
break;
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!saveGames.has(slot)) {
response = { success: false, error: 'missing_save', result: false };
break;
}
startDemoGame();
socket.emit('gameLoaded', { slot });
response = { success: true, result: true, running: true, slot };
break;
}
case 'saveGame':
case 'saveGame()': {
if (!gameRunning) {
response = { success: false, error: 'game_not_running', result: false };
break;
}
const slot = normalizeSaveSlot(args[0]);
saveGames.add(slot);
socket.emit('gameSaved', { slot });
response = { success: true, result: true, slot };
break;
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
response = { success: true, result: saveGames.has(slot), slot };
break;
}
case 'getSaveGames':
case 'getSaveGames()':
response = { success: true, result: Array.from(saveGames).sort((a, b) => a - b) };
break;
case 'isGameRunning':
case 'isGameRunning()':
response = { success: true, result: gameRunning };
break;
default:
response = { success: false, error: `unknown_method:${method}` };
}
if (typeof respond === 'function') respond(response);
} catch (error) {
if (typeof respond === 'function') {
respond({ success: false, error: error instanceof Error ? error.message : String(error) });
}
}
});
// Start a new game
socket.on('startGame', async () => {
try {
console.log('Starting test game session');
// Send introduction to client
socket.emit('gameIntroduction', {
introduction: "Welcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
});
startDemoGame();
} catch (error) {
console.error('Error starting game:', error);
@@ -61,12 +143,9 @@ io.on('connection', (socket) => {
try {
console.log(`Received command: ${data.command}`);
// Move to the next paragraph
currentParagraphIndex = (currentParagraphIndex + 1) % TEST_PARAGRAPHS.length;
// Send narrative response to client
socket.emit('narrativeResponse', {
text: TEST_PARAGRAPHS[currentParagraphIndex],
text: data.command,
gameState: {
currentRoomId: "test-room"
},
@@ -92,6 +171,8 @@ function ensureDirectories() {
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/music'),
path.join(__dirname, '../public/sounds'),
path.join(__dirname, '../public/fonts')
];