Add Ink session recovery and Coolify Docker support

This commit is contained in:
2026-05-19 13:14:46 +02:00
parent dbcb8f4284
commit ebc8e1c7df
14 changed files with 290 additions and 20 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.env.*
!.env.example
npm-debug.log*
coverage
.nyc_output
data/ink-src/eibenreith.old.ink
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV DEFAULT_GAME_ENGINE=ink
ENV PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
COPY --from=build /app/config ./config
COPY --from=build /app/data ./data
COPY --from=build /app/scripts ./scripts
EXPOSE 3000
CMD ["node", "dist/server-ink.js"]
+15
View File
@@ -36,6 +36,21 @@ npm run dev:cli # Run the CLI interface through ts-node/nodemon
Each game engine also has `:debug` and `:inspect` variants. `:debug` enables engine-specific diagnostic logging. `:inspect` starts Node with the inspector and currently also enables that engine's debug flag, so it is the combined debug-plus-inspector mode.
## Docker / Coolify Ink Deployment
The included `Dockerfile` builds and serves the Ink engine only. Coolify can use the repository Dockerfile directly.
Set the Coolify environment variables from `coolify.env.example`; at minimum:
```text
NODE_ENV=production
DEFAULT_GAME_ENGINE=ink
PORT=3000
INK_CONFIG_FILE=./config/engines/ink.json
```
The container compiles TypeScript during image build and compiles the configured Ink source to JSON when the server starts.
## Configuration
Environment variables are loaded from `.env`.
+11
View File
@@ -61,6 +61,15 @@ isGameRunning()
chooseChoice(index)
```
The Ink engine additionally supports browser-owned session recovery:
```text
resumeGame(savedInkState)
exportGameState()
```
`exportGameState()` returns the current Ink state without creating a server-side save slot. The client stores that state with story history, choices, input mode, and media state in IndexedDB. `resumeGame(savedInkState)` rehydrates a fresh server-side InkEngine after a socket reconnect or browser reload without emitting duplicate narrative. This keeps durable player-specific state client-side for hosted multi-client Ink deployments.
Line-input engines also use `playerCommand` for free text.
Every engine emits `TurnResult` objects:
@@ -103,6 +112,8 @@ The YAML engine is no longer the architectural default; it is one engine beside
The Ink server compiles source at startup using `inkjs/full`, then runs the compiled story with `inkjs`. Ink choices become `ChoiceResult` objects. Ink tags become shared `StoryTag` objects. Choice preview tags support `#key`, `#letter`, `#optional`, `#action`, `#gated`, and `#sort`.
The server keeps only ephemeral per-socket InkEngine instances. Browser IndexedDB owns durable Ink saves and the current autosave. If the socket reconnects or the page reloads, the browser sends the autosaved Ink state to `resumeGame()` and restores rendered history locally.
Ink does not provide arbitrary string input as a native async primitive comparable to choices. Future text-input turns should be implemented through a tag such as `#input[name](prompt)`: the server returns `inputMode: 'text'`, the UI shows command input for one round, then the server stores the submitted string into an Ink variable and continues.
### Z-code Engine
+11
View File
@@ -0,0 +1,11 @@
# Coolify environment for the Ink-only web deployment.
# Set these in Coolify's environment variable UI, not in a committed .env file.
NODE_ENV=production
DEFAULT_GAME_ENGINE=ink
PORT=3000
INK_CONFIG_FILE=./config/engines/ink.json
# Optional server-side LLM variables are only needed by non-Ink engines.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=
+2
View File
@@ -16,7 +16,9 @@ export declare class InkEngine {
newGame(): TurnResult;
chooseChoice(choiceIndex: number): TurnResult;
saveGame(): string;
resumeGame(savedState: string): void;
loadGame(savedState: string): TurnResult;
private restoreState;
private loadStory;
private continueStory;
private getChoiceTags;
+7 -1
View File
@@ -90,7 +90,14 @@ class InkEngine {
nextTurnId: this.nextTurnId,
});
}
resumeGame(savedState) {
this.restoreState(savedState);
}
loadGame(savedState) {
this.restoreState(savedState);
return this.continueStory();
}
restoreState(savedState) {
this.story = this.loadStory();
let inkState = savedState;
try {
@@ -106,7 +113,6 @@ class InkEngine {
// Backward compatibility with raw Ink state JSON.
}
this.story.state.LoadJson(inkState);
return this.continueStory();
}
loadStory() {
const resolvedPath = path_1.default.resolve(this.storyPath);
+1 -1
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -147,6 +147,25 @@ async function handleGameApi(socket, method, args) {
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
case 'resumeGame':
case 'resumeGame()': {
const browserSave = typeof args[0] === 'string' ? args[0] : null;
if (!browserSave) {
return { success: false, error: 'missing_state', result: false };
}
const engine = new ink_engine_1.InkEngine(getStoryPath());
engine.resumeGame(browserSave);
sessions.set(socket.id, engine);
return { success: true, result: true, running: engine.isRunning() };
}
case 'exportGameState':
case 'exportGameState()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
return { success: true, result: true, savedState: engine.saveGame() };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);
+1 -1
View File
File diff suppressed because one or more lines are too long
+138 -14
View File
@@ -23,6 +23,13 @@ class GameLoopModule extends BaseModule {
};
this.isRunning = false;
this.autoSaveSlot = 'autosave';
this.currentChoices = [];
this.currentInputMode = 'none';
this.autoSaveInProgress = false;
this.autoSaveQueued = false;
this.resumeAttempted = false;
this.lastInkState = null;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -34,6 +41,9 @@ class GameLoopModule extends BaseModule {
'refreshGameApiState',
'hasSaveGame',
'queueUnrenderedHistoryBlocks',
'autoSaveCurrentSession',
'restoreBrowserSave',
'resumeAutosaveIfAvailable',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -74,6 +84,7 @@ class GameLoopModule extends BaseModule {
document.addEventListener('ui:game:save', () => this.requestSaveGame());
document.addEventListener('ui:game:load', () => this.requestLoadGame());
document.addEventListener('story:input-mode', (event) => {
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
if (event.detail !== 'end') {
return;
}
@@ -81,6 +92,18 @@ class GameLoopModule extends BaseModule {
this.gameState.ended = true;
this.gameState.canSave = false;
this.updateUIState();
this.autoSaveCurrentSession();
});
document.addEventListener('story:choices', (event) => {
this.currentChoices = Array.isArray(event.detail) ? event.detail : [];
});
document.addEventListener('story:turn-complete', (event) => {
const detail = event.detail || {};
this.currentChoices = Array.isArray(detail.choices) ? detail.choices : this.currentChoices;
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(detail.inputMode)
? detail.inputMode
: this.currentInputMode;
this.autoSaveCurrentSession();
});
}
@@ -109,6 +132,10 @@ class GameLoopModule extends BaseModule {
this.refreshGameApiState();
});
socketClient.on('disconnect', () => {
this.resumeAttempted = false;
});
// Listen for game state updates
socketClient.on('gameStateUpdate', (data) => {
console.log("GameLoop: Game state update received", data);
@@ -153,6 +180,10 @@ class GameLoopModule extends BaseModule {
]);
this.gameState.started = Boolean(running?.result);
if (!this.gameState.started) {
const resumed = await this.resumeAutosaveIfAvailable();
if (resumed) return;
}
if (this.gameState.started) {
this.gameState.startedOnce = true;
this.gameState.ended = false;
@@ -162,6 +193,41 @@ class GameLoopModule extends BaseModule {
this.updateUIState();
}
async resumeAutosaveIfAvailable() {
if (this.resumeAttempted) return false;
this.resumeAttempted = true;
const storyHistory = this.getModule('story-history');
const socketClient = this.getModule('socket-client');
if (!storyHistory || !socketClient || typeof storyHistory.loadSlot !== 'function') {
return false;
}
const browserSave = await storyHistory.loadSlot(this.autoSaveSlot);
if (!browserSave?.inkState || browserSave.running === false) {
return false;
}
const response = await socketClient.resumeGame(browserSave.inkState);
if (!response?.success) {
console.warn('GameLoop: autosave resume failed', response);
return false;
}
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: true });
this.gameState.started = Boolean(response.running);
this.gameState.startedOnce = true;
this.gameState.ended = !response.running && browserSave.inputMode === 'end';
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = true;
this.currentChoices = Array.isArray(browserSave.choices) ? browserSave.choices : [];
this.currentInputMode = browserSave.inputMode || 'none';
document.dispatchEvent(new CustomEvent('story:choices', { detail: this.currentChoices }));
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: this.currentInputMode }));
this.updateUIState();
return true;
}
/**
* Update the game state
* @param {Object} data - New game state data
@@ -249,8 +315,12 @@ class GameLoopModule extends BaseModule {
inkState: response.savedState || null,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null
musicState: audioManager?.getMusicState?.() || null,
choices: this.currentChoices,
inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended
});
this.lastInkState = response.savedState || this.lastInkState;
}
this.gameState.canLoad = true;
this.updateUIState();
@@ -281,9 +351,31 @@ class GameLoopModule extends BaseModule {
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
await this.restoreBrowserSave(browserSave, 'load-game', { resetDisplay: true });
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (response?.success && browserSave && Array.isArray(browserSave.choices)) {
this.currentChoices = browserSave.choices;
this.currentInputMode = browserSave.inputMode || this.currentInputMode;
}
if (response?.success) {
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
}
}
async restoreBrowserSave(browserSave, reason = 'load-game', options = {}) {
const storyHistory = this.getModule('story-history');
if (!browserSave || !storyHistory) return;
if (options.resetDisplay) {
await this.resetClientPlaybackAndDisplay();
}
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'load-game' }
detail: { active: true, reason }
}));
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
storyHistory.setCurrentGame(
@@ -310,21 +402,12 @@ class GameLoopModule extends BaseModule {
detail: { state: 'waiting-generating', reason: 'restoring-pending-output' }
}));
}
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (response?.success && hasUnrenderedHistory) {
if (hasUnrenderedHistory) {
await this.queueUnrenderedHistoryBlocks(browserSave);
}
if (response?.success) {
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
}
if (!hasUnrenderedHistory) {
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'load-game-complete' }
detail: { active: false, reason: `${reason}-complete` }
}));
} else {
const clearRestoring = () => {
@@ -347,6 +430,47 @@ class GameLoopModule extends BaseModule {
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
}
async autoSaveCurrentSession() {
if (!this.gameState.startedOnce || this.autoSaveInProgress) {
this.autoSaveQueued = this.autoSaveInProgress;
return;
}
const socketClient = this.getModule('socket-client');
const storyHistory = this.getModule('story-history');
if (!socketClient || !storyHistory || typeof storyHistory.saveSlot !== 'function') {
return;
}
this.autoSaveInProgress = true;
try {
const response = this.gameState.started && typeof socketClient.exportGameState === 'function'
? await socketClient.exportGameState()
: null;
if (!response?.success || !response.savedState) {
return;
}
this.lastInkState = response.savedState;
const audioManager = this.getModule('audio-manager');
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: response.savedState,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null,
choices: this.currentChoices,
inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended
});
} finally {
this.autoSaveInProgress = false;
if (this.autoSaveQueued) {
this.autoSaveQueued = false;
this.autoSaveCurrentSession();
}
}
}
async queueUnrenderedHistoryBlocks(saveRecord = {}) {
const storyHistory = this.getModule('story-history');
const textBuffer = this.getModule('text-buffer');
+16 -2
View File
@@ -19,8 +19,9 @@ class SocketClientModule extends BaseModule {
this.storyHistory = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.maxReconnectAttempts = Infinity;
this.reconnectDelay = 2000;
this.maxReconnectDelay = 30000;
this.url = null;
this.eventListeners = {};
this.defaultHost = 'localhost:3000';
@@ -41,6 +42,8 @@ class SocketClientModule extends BaseModule {
'newGame',
'loadGame',
'saveGame',
'resumeGame',
'exportGameState',
'chooseChoice',
'hasSaveGame',
'getSaveGames',
@@ -279,6 +282,9 @@ class SocketClientModule extends BaseModule {
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
this.dispatchChoices(choices);
this.dispatchInputMode(inputMode);
document.dispatchEvent(new CustomEvent('story:turn-complete', {
detail: { turnId, turn: data, choices, inputMode }
}));
if (turnBlocks.length === 0 && choices.length > 0) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
@@ -658,7 +664,7 @@ class SocketClientModule extends BaseModule {
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
const delay = Math.min(this.maxReconnectDelay, this.reconnectDelay * this.reconnectAttempts);
console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
@@ -808,6 +814,14 @@ class SocketClientModule extends BaseModule {
return this.callGameApi('saveGame', [slot]);
}
resumeGame(savedState) {
return this.callGameApi('resumeGame', [savedState]);
}
exportGameState() {
return this.callGameApi('exportGameState', []);
}
chooseChoice(choiceIndex) {
return this.callGameApi('chooseChoice', [choiceIndex]);
}
+9 -1
View File
@@ -117,7 +117,16 @@ export class InkEngine {
});
}
resumeGame(savedState: string): void {
this.restoreState(savedState);
}
loadGame(savedState: string): TurnResult {
this.restoreState(savedState);
return this.continueStory();
}
private restoreState(savedState: string): void {
this.story = this.loadStory();
let inkState = savedState;
try {
@@ -132,7 +141,6 @@ export class InkEngine {
// Backward compatibility with raw Ink state JSON.
}
this.story.state.LoadJson(inkState);
return this.continueStory();
}
private loadStory(): Story {
+21
View File
@@ -140,6 +140,27 @@ async function handleGameApi(
return { success: true, result: true, running: true, slot };
}
case 'resumeGame':
case 'resumeGame()': {
const browserSave = typeof args[0] === 'string' ? args[0] : null;
if (!browserSave) {
return { success: false, error: 'missing_state', result: false };
}
const engine = new InkEngine(getStoryPath());
engine.resumeGame(browserSave);
sessions.set(socket.id, engine);
return { success: true, result: true, running: engine.isRunning() };
}
case 'exportGameState':
case 'exportGameState()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
return { success: true, result: true, savedState: engine.saveGame() };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);