npm package: @series-inc/slinky-core
Description: Core modules for the Slinky project
Author: Series Inc
Latest version: 1.0.0

# @series-inc/slinky-core

The foundational engine for running Slinky interactive stories. This package provides the core runtime for compiling, parsing, and advancing [Ink](https://github.com/inkle/ink)-based narratives with a custom directive system on top.

## What It Does

`slinky-core` is a **platform-agnostic narrative engine** that sits between raw Ink story files and whatever rendering layer you build (Phaser scenes, React components, etc.). It handles:

- **Compiling** Ink source into a runnable story
- **Parsing** custom `@DIRECTIVE` annotations embedded in Ink content
- **Advancing** the story frame by frame, where each frame is a "stopping point" containing directives, a single dialogue message, and available choices
- **Saving and loading** story state via minimal snapshots and choice-history replay

It does **not** handle rendering, asset loading, or UI — those concerns belong to consumer packages like `@series-inc/slinky-phaser` or individual game applications.

## Architecture

```
Ink Source (.ink file)
        │
        ▼
  ┌─────────────┐
  │ parseStory() │  ← Extracts @DIRECTIVE metadata, cleans content
  └──────┬──────┘
         │
         ▼
  ┌──────────────────────┐
  │ compileStoryStandard()│  ← Compiles Ink to JSON via inkjs
  └──────┬───────────────┘
         │
         ▼
  ┌─────────────────────────────────────┐
  │ StoryEngine (implements IStoryEngine)│  ← Composes services, manages story
  └──────┬──────────────────────────────┘
         │ composes
         ▼
  ┌─────────────────────────────────────┐
  │ Services (SOLID Architecture)        │
  │ • StoryStateManager                  │  ← State management
  │ • InkVariableManager                 │  ← Variables & visit counts
  │ • ChoiceHistoryManager               │  ← Choice history & replay
  │ • StoryInitializer (static)          │  ← Initialization
  └──────┬──────────────────────────────┘
         │ uses
         ▼
  ┌──────────────┐
  │ buildFrame() │  ← Static utility: reads lines, builds frames
  └──────────────┘
```

### Key Concept: Frames

A **SlinkyFrame** is the atomic unit of story progression. Each time you call `engine.continue()`, the `FrameBuilder` reads lines from the Ink story until it hits dialogue or choices. Everything before that stopping point (scene changes, character updates, etc.) is collected as **directives**.

```typescript
type SlinkyFrame = {
  directives: Directive[];         // @SCENE, @CHARACTER, etc.
  message: IStoryMessage | null;   // The dialogue line (speaker + text)
  choices: IChoice[];              // Player choices, if any
  metadata: FrameMetadata;         // canContinue, isEnd, path info, tags
};
```

### Key Concept: Directives

Directives are lines in the Ink file that start with `@` and carry structured parameters:

```
@SCENE_UPDATE: mode=avatar, background=bedroom, music=ambient-night
@CHARACTER_UPDATE: id=Alice, outfit=casual, side=left
```

`slinky-core` **parses** these into `Directive` objects but does **not** act on them. It is the consumer's responsibility to interpret directives and apply them to the rendering layer. The `DirectiveRegistry` pattern is provided for this purpose.

**Note**: Directive names and parameters are game-specific. The examples above are from the `basic-game` application. Your game may define different directives.

## Core API

### StoryEngine

The main entry point for running a story.

```typescript
import { StoryEngine } from '@series-inc/slinky-core';

const engine = new StoryEngine();

// Initialize with raw Ink content
await engine.initialize(inkSource);

// Advance to the next frame
const frame = await engine.continue();

// Make a choice
const nextFrame = await engine.chooseIndex(0);

// Read/write Ink variables
engine.setInkVariable('player_name', 'Jordan');
const name = engine.getInkVariable('player_name');

// Navigate to a specific knot
engine.jumpToPath('chapter_2');

// Replay a sequence of choices (used for save restoration)
await engine.replayChoices(choiceHistory);

// Access current state
const state = engine.currentState;
```

### Compiler

Compile raw Ink source to JSON, either standalone or with a file handler for multi-file stories.

```typescript
import { compileStoryStandard, compileStoryWithFileHandler } from '@series-inc/slinky-core';

// Simple compilation
const storyJson = compileStoryStandard(inkSource);

// With file handler (for INCLUDE statements)
const storyJson = compileStoryWithFileHandler({
  storyContent: inkSource,
  storyPath: 'story.ink',
  fileHandler: myFileHandler,
});
```

### Directive Parser

Parse story content to extract directives and optionally build metadata.

```typescript
import { parseStory, parseDirective } from '@series-inc/slinky-core';

// Basic: just clean the content
const { content } = parseStory(inkSource);

// With metadata extraction
const { metadata, content } = parseStory(inkSource, {
  initMetadata: () => ({ characters: [], scenes: [] }),
  onDirective(directive, metadata, lineNumber) {
    if (directive.type === 'CHARACTER_SCHEMA') {
      metadata.characters.push(directive.params);
    }
  },
});

// Parse a single directive line
const directive = parseDirective('@SCENE_UPDATE: background=bedroom, music=morning');
// → { type: 'SCENE_UPDATE', params: { background: 'bedroom', music: 'morning' } }
```

### DirectiveRegistry

A registry pattern for dispatching directives to type-safe handlers with Zod validation.

```typescript
import { DirectiveRegistry, DirectiveHandler } from '@series-inc/slinky-core';
import { z } from 'zod';

// Define a handler
class SceneUpdateHandler extends DirectiveHandler<MyContext, z.infer<typeof schema>> {
  protected readonly directiveName = 'SCENE_UPDATE';
  protected readonly paramSchema = z.object({
    background: z.string().optional(),
    music: z.string().optional(),
    mode: z.enum(['avatar', 'chat', 'prose']).optional(),
  });

  protected async execute(params, context) {
    // Apply scene changes to your rendering layer
    await context.scene.updateScene(params);
  }
}

// Register and execute
const registry = new DirectiveRegistry<MyContext>();
registry.register('SCENE_UPDATE', new SceneUpdateHandler());

const result = await registry.execute(directive, context);
```

### SaveModule

Manages save/load using minimal snapshots and choice-history replay. The module does **not** own storage — it only extracts and restores state. Persistence is the consumer's responsibility.

```typescript
import { StoryEngine, SaveModule } from '@series-inc/slinky-core';

const engine = new StoryEngine();
const save = new SaveModule(engine, {
  persistentVariables: ['player_name', 'relationship_score'],
  debug: true,
});

// Take a snapshot
const snapshot = save.getSnapshot();
// → { version, currentPathString, variables, visitCounts, choiceHistory }

// Restore from snapshot (replays choices to reconstruct state)
await save.restoreSnapshot(snapshot);

// Inject cross-chapter variables before starting a new chapter
save.setInitialVariables({
  player_name: 'Jordan',
  relationship_score: 85,
});
```

## Advanced Features

### Dependency Injection for Testing

StoryEngine supports optional dependency injection, enabling you to mock services for testing:

```typescript
import { StoryEngine, StoryStateManager, ChoiceHistoryManager } from '@series-inc/slinky-core';

// Create custom or mock services
const mockStateManager = new StoryStateManager();
const mockHistoryManager = new ChoiceHistoryManager();

// Inject services for testing
const engine = new StoryEngine({
  stateManager: mockStateManager,
  historyManager: mockHistoryManager,
});

// Engine uses your provided services instead of creating its own
```

### Interface Abstraction

The package exports `IStoryEngine` interface for loose coupling between components:

```typescript
import type { IStoryEngine } from '@series-inc/slinky-core';

// Depend on the interface, not the concrete class
function processStory(engine: IStoryEngine) {
  const frame = await engine.continue();
  // Your code works with any IStoryEngine implementation
}
```

**Benefits:**
- Mock implementations for unit testing
- Alternative StoryEngine implementations
- Better separation of concerns
- Easier refactoring

### SOLID Architecture

StoryEngine is composed of focused, single-responsibility services:

| Service | Responsibility | Exported |
|---------|---------------|----------|
| **StoryStateManager** | Manages ephemeral story state (initiated, blocking, currentFrame, lastValidPath) | ✅ Yes |
| **InkVariableManager** | Manages ink variables and visit counts | ✅ Yes |
| **ChoiceHistoryManager** | Records and replays choice history with silent mode | ✅ Yes |
| **StoryInitializer** | Static utility for story initialization from Ink content | ✅ Yes |

Each service can be tested in isolation and follows SOLID principles. Services are injected into StoryEngine via the constructor, enabling dependency injection patterns.

### Static Utilities

For stateless operations, the package provides efficient static functions:

```typescript
import { buildFrame } from '@series-inc/slinky-core';

// Build a frame from an inkjs Story instance
const frame = buildFrame(story);
```

**Note:** The `FrameBuilder` class is deprecated but still available for backward compatibility. New code should use the `buildFrame()` static function.

## Exported Interfaces & Services

### Interfaces

- **`IStoryEngine`** - Public contract for StoryEngine, enabling interface-based programming
- **`IStoryEngineServices`** - Service container type for dependency injection

### Services

All services are exported and can be used independently or injected into StoryEngine:

```typescript
import {
  StoryStateManager,
  InkVariableManager,
  ChoiceHistoryManager,
  StoryInitializer,
} from '@series-inc/slinky-core';
```

## Provided Type Definitions

The package also exports **consumer-facing types** for common visual novel concepts. These are convenience types and are not used by the engine itself:

- **Scene types**: `SceneDefinition`, `Background`, `Scene`, `TransitionConfig`, `BackgroundPayload`, `MusicPayload`
- **Character types**: `CharacterDefinition`, `Character`, `CharacterLook`, `ItemProp`
- **Constants**: `TransitionType`, `BackgroundAnchor`, `BackgroundType`, `emotions`, `characterSides`, `itemPropTypes`

## Documentation

### For Users

- **[WRITING-WITH-INK.md](src/docs/WRITING-WITH-INK.md)** - Complete Ink language reference
- **[RUNNING-ink.md](src/docs/RUNNING-ink.md)** - How to run Ink stories
- **[INK-JS-DOCS.md](src/docs/INK-JS-DOCS.md)** - inkjs API reference
- **[INK-EXTERNAL-FUNCTIONS.md](src/docs/INK-EXTERNAL-FUNCTIONS.md)** - External function integration

For game-specific directive documentation (like `@SCENE`, `@CHARACTER`, etc.), see the [basic-game documentation](../../apps/basic-game/docs/WRITING-SLINKY.md).

### For Developers & Contributors

- **[AGENTS.md](./AGENTS.md)** - Detailed guide for AI agents and developers working on slinky-core
  - Testing patterns and best practices
  - Common workflows and pitfalls
  - Architecture deep dive
- **[CONTRIBUTING.md](../../CONTRIBUTING.md)** - Contribution guidelines for the Slinky monorepo

## Build & Development

```bash
# Build the package
pnpm --filter @series-inc/slinky-core build

# Watch mode for development
pnpm --filter @series-inc/slinky-core dev

# Type checking
pnpm --filter @series-inc/slinky-core typecheck
```

The package outputs ESM with TypeScript declarations via `tsdown`. The single entry point is `./dist/index.mjs`.

## Dependencies

| Dependency | Purpose |
|---|---|
| `inkjs` | The Ink narrative scripting runtime (compiler + story engine) |
| `zod` | Schema validation for directive parameters |
| `es-toolkit` | Utility functions (e.g. `cloneDeep`) |