Add Ink session recovery and Coolify Docker support
This commit is contained in:
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
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
|
## Configuration
|
||||||
|
|
||||||
Environment variables are loaded from `.env`.
|
Environment variables are loaded from `.env`.
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ isGameRunning()
|
|||||||
chooseChoice(index)
|
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.
|
Line-input engines also use `playerCommand` for free text.
|
||||||
|
|
||||||
Every engine emits `TurnResult` objects:
|
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 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.
|
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
|
### Z-code Engine
|
||||||
|
|||||||
@@ -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=
|
||||||
Vendored
+2
@@ -16,7 +16,9 @@ export declare class InkEngine {
|
|||||||
newGame(): TurnResult;
|
newGame(): TurnResult;
|
||||||
chooseChoice(choiceIndex: number): TurnResult;
|
chooseChoice(choiceIndex: number): TurnResult;
|
||||||
saveGame(): string;
|
saveGame(): string;
|
||||||
|
resumeGame(savedState: string): void;
|
||||||
loadGame(savedState: string): TurnResult;
|
loadGame(savedState: string): TurnResult;
|
||||||
|
private restoreState;
|
||||||
private loadStory;
|
private loadStory;
|
||||||
private continueStory;
|
private continueStory;
|
||||||
private getChoiceTags;
|
private getChoiceTags;
|
||||||
|
|||||||
Vendored
+7
-1
@@ -90,7 +90,14 @@ class InkEngine {
|
|||||||
nextTurnId: this.nextTurnId,
|
nextTurnId: this.nextTurnId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
resumeGame(savedState) {
|
||||||
|
this.restoreState(savedState);
|
||||||
|
}
|
||||||
loadGame(savedState) {
|
loadGame(savedState) {
|
||||||
|
this.restoreState(savedState);
|
||||||
|
return this.continueStory();
|
||||||
|
}
|
||||||
|
restoreState(savedState) {
|
||||||
this.story = this.loadStory();
|
this.story = this.loadStory();
|
||||||
let inkState = savedState;
|
let inkState = savedState;
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +113,6 @@ class InkEngine {
|
|||||||
// Backward compatibility with raw Ink state JSON.
|
// Backward compatibility with raw Ink state JSON.
|
||||||
}
|
}
|
||||||
this.story.state.LoadJson(inkState);
|
this.story.state.LoadJson(inkState);
|
||||||
return this.continueStory();
|
|
||||||
}
|
}
|
||||||
loadStory() {
|
loadStory() {
|
||||||
const resolvedPath = path_1.default.resolve(this.storyPath);
|
const resolvedPath = path_1.default.resolve(this.storyPath);
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+19
@@ -147,6 +147,25 @@ async function handleGameApi(socket, method, args) {
|
|||||||
socket.emit('gameLoaded', { slot });
|
socket.emit('gameLoaded', { slot });
|
||||||
return { success: true, result: true, running: true, 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':
|
||||||
case 'saveGame()': {
|
case 'saveGame()': {
|
||||||
const engine = sessions.get(socket.id);
|
const engine = sessions.get(socket.id);
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+138
-14
@@ -23,6 +23,13 @@ class GameLoopModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.isRunning = false;
|
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
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -34,6 +41,9 @@ class GameLoopModule extends BaseModule {
|
|||||||
'refreshGameApiState',
|
'refreshGameApiState',
|
||||||
'hasSaveGame',
|
'hasSaveGame',
|
||||||
'queueUnrenderedHistoryBlocks',
|
'queueUnrenderedHistoryBlocks',
|
||||||
|
'autoSaveCurrentSession',
|
||||||
|
'restoreBrowserSave',
|
||||||
|
'resumeAutosaveIfAvailable',
|
||||||
'requestStartGame',
|
'requestStartGame',
|
||||||
'requestSaveGame',
|
'requestSaveGame',
|
||||||
'requestLoadGame',
|
'requestLoadGame',
|
||||||
@@ -74,6 +84,7 @@ class GameLoopModule extends BaseModule {
|
|||||||
document.addEventListener('ui:game:save', () => this.requestSaveGame());
|
document.addEventListener('ui:game:save', () => this.requestSaveGame());
|
||||||
document.addEventListener('ui:game:load', () => this.requestLoadGame());
|
document.addEventListener('ui:game:load', () => this.requestLoadGame());
|
||||||
document.addEventListener('story:input-mode', (event) => {
|
document.addEventListener('story:input-mode', (event) => {
|
||||||
|
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
|
||||||
if (event.detail !== 'end') {
|
if (event.detail !== 'end') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,6 +92,18 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.gameState.ended = true;
|
this.gameState.ended = true;
|
||||||
this.gameState.canSave = false;
|
this.gameState.canSave = false;
|
||||||
this.updateUIState();
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +131,10 @@ class GameLoopModule extends BaseModule {
|
|||||||
|
|
||||||
this.refreshGameApiState();
|
this.refreshGameApiState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socketClient.on('disconnect', () => {
|
||||||
|
this.resumeAttempted = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for game state updates
|
// Listen for game state updates
|
||||||
socketClient.on('gameStateUpdate', (data) => {
|
socketClient.on('gameStateUpdate', (data) => {
|
||||||
@@ -153,6 +180,10 @@ class GameLoopModule extends BaseModule {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.gameState.started = Boolean(running?.result);
|
this.gameState.started = Boolean(running?.result);
|
||||||
|
if (!this.gameState.started) {
|
||||||
|
const resumed = await this.resumeAutosaveIfAvailable();
|
||||||
|
if (resumed) return;
|
||||||
|
}
|
||||||
if (this.gameState.started) {
|
if (this.gameState.started) {
|
||||||
this.gameState.startedOnce = true;
|
this.gameState.startedOnce = true;
|
||||||
this.gameState.ended = false;
|
this.gameState.ended = false;
|
||||||
@@ -161,6 +192,41 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.gameState.canLoad = Boolean(hasSave?.result);
|
this.gameState.canLoad = Boolean(hasSave?.result);
|
||||||
this.updateUIState();
|
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
|
* Update the game state
|
||||||
@@ -249,8 +315,12 @@ class GameLoopModule extends BaseModule {
|
|||||||
inkState: response.savedState || null,
|
inkState: response.savedState || null,
|
||||||
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
|
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
|
||||||
renderedLineCount: storyHistory.renderedLineCount || 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.gameState.canLoad = true;
|
||||||
this.updateUIState();
|
this.updateUIState();
|
||||||
@@ -281,9 +351,31 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.gameState.canSave = true;
|
this.gameState.canSave = true;
|
||||||
this.gameState.canLoad = true;
|
this.gameState.canLoad = true;
|
||||||
this.updateUIState();
|
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', {
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
detail: { active: true, reason: 'load-game' }
|
detail: { active: true, reason }
|
||||||
}));
|
}));
|
||||||
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
|
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
|
||||||
storyHistory.setCurrentGame(
|
storyHistory.setCurrentGame(
|
||||||
@@ -310,21 +402,12 @@ class GameLoopModule extends BaseModule {
|
|||||||
detail: { state: 'waiting-generating', reason: 'restoring-pending-output' }
|
detail: { state: 'waiting-generating', reason: 'restoring-pending-output' }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
|
if (hasUnrenderedHistory) {
|
||||||
if (response?.success && hasUnrenderedHistory) {
|
|
||||||
await this.queueUnrenderedHistoryBlocks(browserSave);
|
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) {
|
if (!hasUnrenderedHistory) {
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
detail: { active: false, reason: 'load-game-complete' }
|
detail: { active: false, reason: `${reason}-complete` }
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const clearRestoring = () => {
|
const clearRestoring = () => {
|
||||||
@@ -347,6 +430,47 @@ class GameLoopModule extends BaseModule {
|
|||||||
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
|
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 = {}) {
|
async queueUnrenderedHistoryBlocks(saveRecord = {}) {
|
||||||
const storyHistory = this.getModule('story-history');
|
const storyHistory = this.getModule('story-history');
|
||||||
const textBuffer = this.getModule('text-buffer');
|
const textBuffer = this.getModule('text-buffer');
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ class SocketClientModule extends BaseModule {
|
|||||||
this.storyHistory = null;
|
this.storyHistory = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = Infinity;
|
||||||
this.reconnectDelay = 2000;
|
this.reconnectDelay = 2000;
|
||||||
|
this.maxReconnectDelay = 30000;
|
||||||
this.url = null;
|
this.url = null;
|
||||||
this.eventListeners = {};
|
this.eventListeners = {};
|
||||||
this.defaultHost = 'localhost:3000';
|
this.defaultHost = 'localhost:3000';
|
||||||
@@ -41,6 +42,8 @@ class SocketClientModule extends BaseModule {
|
|||||||
'newGame',
|
'newGame',
|
||||||
'loadGame',
|
'loadGame',
|
||||||
'saveGame',
|
'saveGame',
|
||||||
|
'resumeGame',
|
||||||
|
'exportGameState',
|
||||||
'chooseChoice',
|
'chooseChoice',
|
||||||
'hasSaveGame',
|
'hasSaveGame',
|
||||||
'getSaveGames',
|
'getSaveGames',
|
||||||
@@ -279,6 +282,9 @@ class SocketClientModule extends BaseModule {
|
|||||||
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
|
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
|
||||||
this.dispatchChoices(choices);
|
this.dispatchChoices(choices);
|
||||||
this.dispatchInputMode(inputMode);
|
this.dispatchInputMode(inputMode);
|
||||||
|
document.dispatchEvent(new CustomEvent('story:turn-complete', {
|
||||||
|
detail: { turnId, turn: data, choices, inputMode }
|
||||||
|
}));
|
||||||
if (turnBlocks.length === 0 && choices.length > 0) {
|
if (turnBlocks.length === 0 && choices.length > 0) {
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
|
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
|
||||||
@@ -658,7 +664,7 @@ class SocketClientModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.reconnectAttempts++;
|
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})`);
|
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]);
|
return this.callGameApi('saveGame', [slot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resumeGame(savedState) {
|
||||||
|
return this.callGameApi('resumeGame', [savedState]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportGameState() {
|
||||||
|
return this.callGameApi('exportGameState', []);
|
||||||
|
}
|
||||||
|
|
||||||
chooseChoice(choiceIndex) {
|
chooseChoice(choiceIndex) {
|
||||||
return this.callGameApi('chooseChoice', [choiceIndex]);
|
return this.callGameApi('chooseChoice', [choiceIndex]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,16 @@ export class InkEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resumeGame(savedState: string): void {
|
||||||
|
this.restoreState(savedState);
|
||||||
|
}
|
||||||
|
|
||||||
loadGame(savedState: string): TurnResult {
|
loadGame(savedState: string): TurnResult {
|
||||||
|
this.restoreState(savedState);
|
||||||
|
return this.continueStory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreState(savedState: string): void {
|
||||||
this.story = this.loadStory();
|
this.story = this.loadStory();
|
||||||
let inkState = savedState;
|
let inkState = savedState;
|
||||||
try {
|
try {
|
||||||
@@ -132,7 +141,6 @@ export class InkEngine {
|
|||||||
// Backward compatibility with raw Ink state JSON.
|
// Backward compatibility with raw Ink state JSON.
|
||||||
}
|
}
|
||||||
this.story.state.LoadJson(inkState);
|
this.story.state.LoadJson(inkState);
|
||||||
return this.continueStory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadStory(): Story {
|
private loadStory(): Story {
|
||||||
|
|||||||
@@ -140,6 +140,27 @@ async function handleGameApi(
|
|||||||
return { success: true, result: true, running: true, 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 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':
|
||||||
case 'saveGame()': {
|
case 'saveGame()': {
|
||||||
const engine = sessions.get(socket.id);
|
const engine = sessions.get(socket.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user