From d14266fb2f313de3a85e6391338ea47cd28efcb8 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 01:29:30 +0800 Subject: [PATCH] feat: add compile-time drift check and sync published types with source StoryAPI Create src/types-drift-check.ts that asserts bidirectional assignability between the source StoryAPI and published types/index.d.ts, so any drift is caught by `npx tsc --noEmit`. Update types/index.d.ts with all missing supporting types (event map, transitions, watch options, actions, storage info, macro definition types) and all missing StoryAPI members (registerClass, defineMacro, storage, actions, events, triggers, transitions, PRNG, config, deferred render). Fix existing types: add prng field to HistoryMoment and SavePayload, make visited/hasVisited/rendered/hasRendered name parameter optional, add getToggle/getList/getRange to SettingsAPI for structural compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types-drift-check.ts | 15 ++ types/index.d.ts | 336 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 src/types-drift-check.ts diff --git a/src/types-drift-check.ts b/src/types-drift-check.ts new file mode 100644 index 0000000..891ffca --- /dev/null +++ b/src/types-drift-check.ts @@ -0,0 +1,15 @@ +/** + * Compile-time check: the hand-written types/index.d.ts must stay in sync + * with the source StoryAPI interface. If this file fails to compile, + * the published types have drifted from the implementation. + * + * Run: npx tsc --noEmit + */ +import type { StoryAPI as SourceAPI } from './story-api'; +import type { StoryAPI as PublishedAPI } from '../types/index'; + +// Both directions — if either fails, the types have drifted. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _sourceToPublished: PublishedAPI = {} as SourceAPI; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _publishedToSource: SourceAPI = {} as PublishedAPI; diff --git a/types/index.d.ts b/types/index.d.ts index 32f7f24..ac2d925 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -14,6 +14,7 @@ export interface HistoryMoment { passage: string; variables: Record; timestamp: number; + prng?: { seed: string; pull: number } | null; } /** @@ -27,6 +28,7 @@ export interface SavePayload { historyIndex: number; visitCounts?: Record; renderCounts?: Record; + prng?: { seed: string; pull: number } | null; } /** @@ -79,6 +81,9 @@ export interface SettingsAPI { addList(name: string, config: ListConfig): void; addRange(name: string, config: RangeConfig): void; get(name: string): unknown; + getToggle(name: string): boolean; + getList(name: string): string; + getRange(name: string): number; set(name: string, value: unknown): void; getAll(): Record; getDefinitions(): Map; @@ -102,6 +107,238 @@ export interface Passage { content: string; } +/** + * Map of story event names to their callback signatures. + * @see {@link ../../src/event-emitter.ts} for the implementation. + */ +export interface StoryEventMap { + storyinit: () => void; + beforerestart: () => void; + actionsChanged: () => void; + variableChanged: ( + changed: Record, + ) => void; + beforesave: ( + slot: string | undefined, + custom: Record | undefined, + ) => void; + aftersave: (slot: string | undefined) => void; + beforeload: (slot: string | undefined) => void; + afterload: (slot: string | undefined) => void; + beforenavigate: (passageName: string) => void; + afternavigate: (to: string, from: string) => void; +} + +/** Event name that can be passed to `Story.on()`. */ +export type StoryEvent = keyof StoryEventMap; + +/** Callback type for a given story event. */ +export type StoryEventCallback = StoryEventMap[E]; + +/** Transition animation type. */ +export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade'; + +/** + * Configuration for passage transitions. + * @see {@link ../../src/transition.ts} for the implementation. + */ +export interface TransitionConfig { + type: TransitionType; + duration?: number; + pause?: number; +} + +/** + * Options for `Story.watch()` trigger registration. + * @see {@link ../../src/triggers.ts} for the implementation. + */ +export interface WatchOptions { + goto?: string; + dialog?: string; + run?: string; + once?: boolean; + name?: string; + priority?: number; +} + +/** Type of interactive action registered by a macro. */ +export type ActionType = + | 'link' + | 'button' + | 'cycle' + | 'textbox' + | 'numberbox' + | 'textarea' + | 'checkbox' + | 'radiobutton' + | 'listbox' + | 'back' + | 'forward' + | 'restart' + | 'save' + | 'load' + | 'dialog'; + +/** + * A registered interactive action (link, button, input, etc.). + * @see {@link ../../src/action-registry.ts} for the implementation. + */ +export interface StoryAction { + id: string; + type: ActionType; + label: string; + target?: string; + variable?: string; + options?: string[]; + value?: unknown; + disabled?: boolean; + perform: (value?: unknown) => void; +} + +/** + * Storage usage information returned by `Story.storage.getInfo()`. + * @see {@link ../../src/saves/types.ts} for the implementation. + */ +export interface StorageInfo { + saveCount: number; + playthroughCount: number; + totalBytes: number; + backend: 'indexeddb' | 'localstorage' | 'memory'; +} + +/** + * Browser storage quota estimate returned by `Story.storage.getQuota()`. + * @see {@link ../../src/saves/types.ts} for the implementation. + */ +export interface StorageQuota { + usage: number; + quota: number; + estimateSupported: boolean; +} + +/** + * Parameter metadata for a macro definition. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface ParameterDef { + name: string; + required?: boolean; + description?: string; +} + +/** + * Metadata about a registered macro, returned by `Story.getMacroRegistry()`. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface MacroMetadata { + name: string; + block: boolean; + subMacros: string[]; + storeVar?: boolean; + interpolate?: boolean; + merged?: boolean; + source: 'builtin' | 'user'; + description?: string; + parameters?: ParameterDef[]; +} + +/** + * Props passed to a macro's render function. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface MacroProps { + rawArgs: string; + className?: string; + id?: string; + children?: any[]; + branches?: Array<{ + rawArgs: string; + className?: string; + id?: string; + children: any[]; + }>; +} + +/** + * Options for registering an interactive action via `ctx.useAction`. + * @see {@link ../../src/hooks/use-action.ts} for the implementation. + */ +export interface UseActionOptions { + type: ActionType; + key: string; + authorId?: string; + label: string; + target?: string; + variable?: string; + options?: string[]; + value?: unknown; + disabled?: boolean; + perform: (value?: unknown) => void; +} + +/** + * Context object passed to a macro's render function alongside props. + * Internal Preact/AST types are represented as `any` since consumers + * may not have Preact type definitions installed. + * @see {@link ../../src/define-macro.ts} for the implementation. + */ +export interface MacroContext { + className?: string; + id?: string; + resolve?: (s: string | undefined) => string | undefined; + cls: string; + mutate: (code: string) => void; + update: (key: string, value: unknown) => void; + getValues: () => Record; + merged?: readonly [ + Record, + Record, + Record, + ]; + varName?: string; + value?: unknown; + setValue?: (value: unknown) => void; + getValue?: () => unknown; + evaluate?: (expr: string) => unknown; + collectText: (nodes: any[]) => string; + sourceLocation: () => string; + parseVarArgs: (rawArgs: string) => { varName: string; placeholder: string }; + extractOptions: (children: any[]) => string[]; + wrap: (content: any) => any; + useAction: (opts: UseActionOptions) => string; + h: (type: any, props: any, ...children: any[]) => any; + renderNodes: ( + nodes: any[], + options?: { nobr?: boolean; locals?: Record }, + ) => any; + renderInlineNodes: (nodes: any[]) => any; + hooks: { + useState: any; + useRef: any; + useEffect: any; + useLayoutEffect: any; + useCallback: any; + useMemo: any; + useContext: any; + }; +} + +/** + * Configuration object for `Story.defineMacro()`. + * @see {@link ../../src/define-macro.ts} for the implementation. + */ +export interface MacroDefinition { + name: string; + subMacros?: string[]; + block?: boolean; + interpolate?: boolean; + merged?: boolean; + storeVar?: boolean; + description?: string; + parameters?: ParameterDef[]; + render: (props: MacroProps, ctx: MacroContext) => any; +} + /** * Metadata about a save slot, returned by `getSaveInfo()` and `listSaves()`. * @see {@link ../../src/saves/types.ts} for the implementation. @@ -166,10 +403,10 @@ export interface StoryAPI { deleteSave(slot?: string): void; /** Return the number of times a passage has been visited. */ - visited(name: string): number; + visited(name?: string): number; /** Check if a passage has been visited at least once. */ - hasVisited(name: string): boolean; + hasVisited(name?: string): boolean; /** Check if any of the given passages have been visited. */ hasVisitedAny(...names: string[]): boolean; @@ -178,10 +415,10 @@ export interface StoryAPI { hasVisitedAll(...names: string[]): boolean; /** Return the number of times a passage has been rendered. */ - rendered(name: string): number; + rendered(name?: string): number; /** Check if a passage has been rendered at least once. */ - hasRendered(name: string): boolean; + hasRendered(name?: string): boolean; /** Check if any of the given passages have been rendered. */ hasRenderedAny(...names: string[]): boolean; @@ -228,4 +465,95 @@ export interface StoryAPI { /** Check whether any dialog is currently open. */ isDialogOpen(): boolean; + + /** Register a class constructor for use in story expressions. */ + registerClass(name: string, ctor: new (...args: any[]) => any): void; + + /** Register a custom macro. */ + defineMacro(config: MacroDefinition): void; + + /** Return metadata for all registered macros. */ + getMacroRegistry(): MacroMetadata[]; + + /** Storage management API. */ + readonly storage: { + /** Get storage usage information (save count, byte size, backend type). */ + getInfo(): Promise; + /** Get browser storage quota estimate. */ + getQuota(): Promise; + /** Delete all saves for the current game. */ + clearGameData(): Promise; + /** Delete all Spindle data across all games. */ + clearAllData(): Promise; + /** Delete a specific playthrough and its saves. */ + deletePlaythrough(playthroughId: string): Promise; + /** The active storage backend. */ + readonly backend: 'indexeddb' | 'localstorage' | 'memory'; + }; + + /** Return all registered interactive actions. */ + getActions(): StoryAction[]; + + /** Perform a registered action by ID. */ + performAction(id: string, value?: unknown): void; + + /** Subscribe to a story event. Returns an unsubscribe function. */ + on( + event: E, + callback: StoryEventCallback, + ): () => void; + + /** Wait for the next frame's actions to be registered, then return them. */ + waitForActions(): Promise; + + /** Register a trigger that fires when a condition expression becomes truthy. Returns an unsubscribe function. */ + watch( + condition: string, + callbackOrOptions: (() => void) | WatchOptions, + ): () => void; + + /** Remove a named trigger registered with `watch()`. */ + unwatch(name: string): void; + + /** Enable or disable the `{nobr}` (no line breaks) rendering mode globally. */ + setNobr(enabled: boolean): void; + + /** Enable or disable the story stylesheet. */ + setCSS(enabled: boolean): void; + + /** Set the default passage transition. Pass `null` to clear. */ + setTransition(config: TransitionConfig | null): void; + + /** Set a one-time transition for the next navigation only. Pass `null` to clear. */ + setNextTransition(config: TransitionConfig | null): void; + + /** Defer initial passage rendering until `ready()` is called. */ + deferRender(): void; + + /** Unblock deferred rendering (call after `deferRender()`). */ + ready(): void; + + /** Return a random float in [0, 1). Uses the seeded PRNG if enabled, otherwise Math.random(). */ + random(): number; + + /** Return a random integer in [min, max] (inclusive). */ + randomInt(min: number, max: number): number; + + /** Story configuration. */ + readonly config: { + /** Maximum number of history moments to retain. */ + maxHistory: number; + }; + + /** Seedable pseudo-random number generator. */ + readonly prng: { + /** Initialize the PRNG with an optional seed. */ + init(seed?: string, useEntropy?: boolean): void; + /** Check whether the seeded PRNG is active. */ + isEnabled(): boolean; + /** The current PRNG seed. */ + readonly seed: string; + /** The number of values pulled from the current seed. */ + readonly pull: number; + }; }