From a6742d8db3af828f36db8364359f0e3a29686dbd Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 17:14:32 +0900 Subject: [PATCH] feat: add gameplay systems and search utilities --- README.md | 10 +- ROADMAP.md | 30 +- docs/index.d.ts | 780 ++++++++++++++++++++++++++++++ docs/list.md | 2 +- examples/fsm.ts | 49 ++ examples/genetic.ts | 62 +++ examples/influenceMap.ts | 20 + examples/inputManager.ts | 48 ++ examples/jsonDiff.ts | 12 +- examples/pagination.ts | 8 + examples/saveManager.ts | 39 ++ examples/screenTransitions.ts | 17 + examples/search.ts | 33 +- examples/soundManager.ts | 35 ++ examples/waveSpawner.ts | 18 + src/ai/fsm.ts | 131 +++++ src/ai/genetic.ts | 135 ++++++ src/ai/influenceMap.ts | 161 ++++++ src/data/flatten.ts | 131 +++++ src/data/jsonDiff.ts | 138 +++--- src/data/pagination.ts | 55 +++ src/gameplay/inputManager.ts | 483 ++++++++++++++++++ src/gameplay/questMachine.ts | 9 +- src/gameplay/saveManager.ts | 322 ++++++++++++ src/gameplay/screenTransitions.ts | 204 ++++++++ src/gameplay/soundManager.ts | 249 ++++++++++ src/gameplay/waveSpawner.ts | 203 ++++++++ src/index.ts | 201 +++++++- src/search/boyerMoore.ts | 87 ++++ src/search/kmp.ts | 55 +++ src/search/lcs.ts | 89 ++++ src/search/rabinKarp.ts | 151 ++++++ src/search/suffixArray.ts | 82 ++++ tests/flatten.test.ts | 32 ++ tests/fsm.test.ts | 89 ++++ tests/genetic.test.ts | 59 +++ tests/index.test.ts | 33 ++ tests/influenceMap.test.ts | 56 +++ tests/inputManager.test.ts | 81 ++++ tests/jsonDiff.test.ts | 12 +- tests/pagination.test.ts | 33 ++ tests/saveManager.test.ts | 103 ++++ tests/screenTransitions.test.ts | 65 +++ tests/search.test.ts | 93 ++++ tests/soundManager.test.ts | 70 +++ tests/waveSpawner.test.ts | 43 ++ 46 files changed, 4725 insertions(+), 93 deletions(-) create mode 100644 examples/fsm.ts create mode 100644 examples/genetic.ts create mode 100644 examples/influenceMap.ts create mode 100644 examples/inputManager.ts create mode 100644 examples/pagination.ts create mode 100644 examples/saveManager.ts create mode 100644 examples/screenTransitions.ts create mode 100644 examples/soundManager.ts create mode 100644 examples/waveSpawner.ts create mode 100644 src/ai/fsm.ts create mode 100644 src/ai/genetic.ts create mode 100644 src/ai/influenceMap.ts create mode 100644 src/data/flatten.ts create mode 100644 src/data/pagination.ts create mode 100644 src/gameplay/inputManager.ts create mode 100644 src/gameplay/saveManager.ts create mode 100644 src/gameplay/screenTransitions.ts create mode 100644 src/gameplay/soundManager.ts create mode 100644 src/gameplay/waveSpawner.ts create mode 100644 src/search/boyerMoore.ts create mode 100644 src/search/kmp.ts create mode 100644 src/search/lcs.ts create mode 100644 src/search/rabinKarp.ts create mode 100644 src/search/suffixArray.ts create mode 100644 tests/flatten.test.ts create mode 100644 tests/fsm.test.ts create mode 100644 tests/genetic.test.ts create mode 100644 tests/influenceMap.test.ts create mode 100644 tests/inputManager.test.ts create mode 100644 tests/pagination.test.ts create mode 100644 tests/saveManager.test.ts create mode 100644 tests/screenTransitions.test.ts create mode 100644 tests/soundManager.test.ts create mode 100644 tests/waveSpawner.test.ts diff --git a/README.md b/README.md index 2c60d37..b25f7a1 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ CDN usage: | Pathfinding & navigation | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | | Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | -| AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | +| AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep`, `createFSM`, `createGeneticAlgorithm`, `computeInfluenceMap` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts`, `ai/fsm.ts`, `ai/genetic.ts`, `ai/influenceMap.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts`, `examples/fsm.ts`, `examples/genetic.ts`, `examples/influenceMap.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts` | -| Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | -| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine`, `createWaveSpawner`, `createSoundManager`, `createInputManager`, `createSaveManager`, `createScreenTransition` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.ts`, `gameplay/waveSpawner.ts`, `gameplay/soundManager.ts`, `gameplay/inputManager.ts`, `gameplay/saveManager.ts`, `gameplay/screenTransitions.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/waveSpawner.ts`, `examples/soundManager.ts`, `examples/inputManager.ts`, `examples/saveManager.ts`, `examples/screenTransitions.ts` | +| Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` | +| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff`, `flatten`, `unflatten` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | | Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/lighting.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/waveSpawner.ts`, `examples/soundManager.ts`, `examples/inputManager.ts`, `examples/saveManager.ts`, `examples/lighting.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index fe5ebf8..05f721d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,27 +64,27 @@ - [x] Combat resolution helpers (cooldowns, damage formulas, status effects) - [x] Quest/dialog state machine utilities - [x] 2D lighting helpers (falloff, blending stubs) - - [ ] Wave spawner utilities for encounter pacing - - [ ] Sound manager stubs (channel limiting, priority) - - [ ] Input manager abstraction (keyboard/mouse/pad remapping) - - [ ] Save/load serialization helpers (slots, integrity checks) - - [ ] Screen transition utilities (fades, wipes, letterboxing) + - [x] Wave spawner utilities for encounter pacing + - [x] Sound manager stubs (channel limiting, priority) + - [x] Input manager abstraction (keyboard/mouse/pad remapping) + - [x] Save/load serialization helpers (slots, integrity checks) + - [x] Screen transition utilities (fades, wipes, letterboxing) ## Milestone 0.5.0 – Algorithm Vault & Data Structures (Planned) - **AI & behaviour expansions** - - [ ] Finite state machine (FSM) toolkit - - [ ] Genetic algorithm utilities - - [ ] Influence map computation helpers + - [x] Finite state machine (FSM) toolkit + - [x] Genetic algorithm utilities + - [x] Influence map computation helpers - **Search & string algorithms** - - [ ] Knuth–Morris–Pratt (KMP) substring search - - [ ] Rabin–Karp multiple pattern matching - - [ ] Boyer–Moore fast substring search - - [ ] Suffix array construction utilities - - [ ] Longest common subsequence (LCS) enhancements and diff helpers + - [x] Knuth–Morris–Pratt (KMP) substring search + - [x] Rabin–Karp multiple pattern matching + - [x] Boyer–Moore fast substring search + - [x] Suffix array construction utilities + - [x] Longest common subsequence (LCS) enhancements and diff helpers - **Data pipelines & utilities** - - [ ] Flatten/unflatten helpers for nested structures - - [ ] Pagination utilities for client-side paging + - [x] Flatten/unflatten helpers for nested structures + - [x] Pagination utilities for client-side paging - [ ] Advanced diff tooling (tree diff, selective patches) - **Visual & simulation tools** - [ ] Color manipulation helpers (RGB/HSL conversion, blending) diff --git a/docs/index.d.ts b/docs/index.d.ts index 2fa1ad9..4265a17 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -73,6 +73,12 @@ export const examples: { readonly Trie: 'examples/search.ts'; readonly binarySearch: 'examples/search.ts'; readonly levenshteinDistance: 'examples/search.ts'; + readonly kmpSearch: 'examples/search.ts'; + readonly rabinKarp: 'examples/search.ts'; + readonly boyerMooreSearch: 'examples/search.ts'; + readonly buildSuffixArray: 'examples/search.ts'; + readonly longestCommonSubsequence: 'examples/search.ts'; + readonly diffStrings: 'examples/search.ts'; }; readonly data: { readonly diff: 'examples/jsonDiff.ts'; @@ -80,6 +86,9 @@ export const examples: { readonly groupBy: 'examples/jsonDiff.ts'; readonly diffJson: 'examples/jsonDiff.ts'; readonly applyJsonDiff: 'examples/jsonDiff.ts'; + readonly flatten: 'examples/jsonDiff.ts'; + readonly unflatten: 'examples/jsonDiff.ts'; + readonly paginate: 'examples/pagination.ts'; }; readonly performance: { readonly debounce: 'examples/requestDedup.ts'; @@ -92,7 +101,28 @@ export const examples: { readonly createWeightedAliasSampler: 'examples/weightedAlias.ts'; readonly createObjectPool: 'examples/objectPool.ts'; readonly fisherYatesShuffle: 'examples/fisherYates.ts'; + }; + readonly gameplay: { + readonly createDeltaTimeManager: 'examples/deltaTime.ts'; readonly createFixedTimestepLoop: 'examples/fixedTimestep.ts'; + readonly createCamera2D: 'examples/camera2D.ts'; + readonly createParticleSystem: 'examples/particleSystem.ts'; + readonly createSpriteAnimation: 'examples/spriteAnimation.ts'; + readonly createTweenSystem: 'examples/tween.ts'; + readonly createPlatformerController: 'examples/platformerPhysics.ts'; + readonly createTopDownController: 'examples/topDownMovement.ts'; + readonly createTileMapController: 'examples/tileMap.ts'; + readonly computeFieldOfView: 'examples/shadowcasting.ts'; + readonly createInventory: 'examples/inventory.ts'; + readonly calculateDamage: 'examples/combat.ts'; + readonly createCooldownController: 'examples/combat.ts'; + readonly createQuestMachine: 'examples/quest.ts'; + readonly computeLightingGrid: 'examples/lighting.ts'; + readonly createWaveSpawner: 'examples/waveSpawner.ts'; + readonly createSoundManager: 'examples/soundManager.ts'; + readonly createInputManager: 'examples/inputManager.ts'; + readonly createSaveManager: 'examples/saveManager.ts'; + readonly createScreenTransition: 'examples/screenTransitions.ts'; }; readonly ai: { readonly seek: 'examples/steering.ts'; @@ -103,6 +133,9 @@ export const examples: { readonly updateBoids: 'examples/boids.ts'; readonly BehaviorTree: 'examples/behaviorTree.ts'; readonly rvoStep: 'examples/rvo.ts'; + readonly createFSM: 'examples/fsm.ts'; + readonly createGeneticAlgorithm: 'examples/genetic.ts'; + readonly computeInfluenceMap: 'examples/influenceMap.ts'; }; readonly graph: { readonly graphBFS: 'examples/graph.ts'; @@ -113,6 +146,7 @@ export const examples: { readonly convexHull: 'examples/geometry.ts'; readonly lineIntersection: 'examples/geometry.ts'; readonly pointInPolygon: 'examples/geometry.ts'; + readonly bresenhamLine: 'examples/bresenham.ts'; }; readonly visual: { readonly easing: 'examples/visual.ts'; @@ -1837,6 +1871,622 @@ export interface LightingGridResult { * Import: gameplay/lighting.ts */ export function computeLightingGrid(options: LightingGridOptions): LightingGridResult; + +/** + * Wave definition describing spawn count and timing. + * Use for: scheduling enemy or event waves. + * Import: gameplay/waveSpawner.ts + */ +export interface WaveDefinition { + delay: number; + count: number; + template: T; + interval?: number; +} + +/** + * Wave spawner configuration options. + * Use for: configuring waves and looping behaviour. + * Import: gameplay/waveSpawner.ts + */ +export interface WaveSpawnerOptions { + waves: ReadonlyArray>; + loop?: boolean; +} + +/** + * Spawn payload emitted by wave spawner updates. + * Import: gameplay/waveSpawner.ts + */ +export interface SpawnPayload { + waveIndex: number; + entityIndex: number; + template: T; +} + +/** + * Wave spawner snapshot for serialization. + * Import: gameplay/waveSpawner.ts + */ +export interface WaveSpawnerSnapshot { + waveIndex: number; + timeUntilNextSpawn: number; + spawnedInWave: number; + looped: number; +} + +/** + * Wave spawner controller API. + * Use for: advancing time and retrieving spawn payloads. + * Import: gameplay/waveSpawner.ts + */ +export interface WaveSpawner { + update(delta: number): SpawnPayload[]; + isFinished(): boolean; + reset(snapshot?: WaveSpawnerSnapshot): void; + toJSON(): WaveSpawnerSnapshot; +} + +/** + * Creates a wave spawner for timed encounters. + * Use for: spawning enemies or events in waves. + * Import: gameplay/waveSpawner.ts + */ +export function createWaveSpawner(options: WaveSpawnerOptions): WaveSpawner; + +/** + * Sound manager configuration options. + * Use for: budgeting audio channels and optional per-channel limits. + * Import: gameplay/soundManager.ts + */ +export interface SoundManagerOptions { + maxChannels: number; + channelLimits?: Record; + getTime?: () => number; +} + +/** + * Options to request playback of a sound. + * Use for: requesting sounds with priority, duration, and metadata. + * Import: gameplay/soundManager.ts + */ +export interface PlaySoundOptions { + soundId: string; + duration: number; + priority?: number; + channel?: string; + metadata?: TMetadata; + time?: number; +} + +/** + * Handle representing an active sound. + * Use for: tracking playing sounds, metadata, and expiry. + * Import: gameplay/soundManager.ts + */ +export interface SoundHandle { + handleId: number; + soundId: string; + channel: string; + priority: number; + startedAt: number; + endsAt: number; + metadata?: TMetadata; +} + +/** + * Result of attempting to play a sound. + * Use for: determining whether playback was accepted or preempted another. + * Import: gameplay/soundManager.ts + */ +export interface PlaySoundResult { + accepted: boolean; + handle?: SoundHandle; + evicted?: SoundHandle; + reason?: 'channel-limit'; +} + +/** + * Sound manager API. + * Use for: coordinating channel usage and expiring sounds. + * Import: gameplay/soundManager.ts + */ +export interface SoundManager { + play(options: PlaySoundOptions): PlaySoundResult; + stop(handleId: number): SoundHandle | null; + update(time?: number): SoundHandle[]; + getActive(): ReadonlyArray>; + setMaxChannels(count: number): void; + getMaxChannels(): number; + reset(): SoundHandle[]; +} + +/** + * Creates a sound manager with channel limiting and priority controls. + * Use for: orchestrating audio playback requests in games. + * Import: gameplay/soundManager.ts + */ +export function createSoundManager( + options: SoundManagerOptions +): SoundManager; + +/** + * Enumerates input action signal types. + * Use for: distinguishing between digital toggles and analog axes. + * Import: gameplay/inputManager.ts + */ +export type InputActionType = 'digital' | 'analog'; + +/** + * Keyboard binding descriptor. + * Use for: mapping keys (with optional modifiers) to actions. + * Import: gameplay/inputManager.ts + */ +export interface KeyboardBinding { + device: 'keyboard'; + key: string; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +/** + * Mouse binding descriptor. + * Use for: connecting pointer buttons to actions. + * Import: gameplay/inputManager.ts + */ +export interface MouseBinding { + device: 'mouse'; + button: number; +} + +/** + * Gamepad button binding descriptor. + * Use for: linking controller buttons to actions. + * Import: gameplay/inputManager.ts + */ +export interface GamepadButtonBinding { + device: 'gamepad-button'; + button: string | number; + gamepadId?: string; +} + +/** + * Gamepad axis binding descriptor. + * Use for: connecting analog stick axes to actions. + * Import: gameplay/inputManager.ts + */ +export interface GamepadAxisBinding { + device: 'gamepad-axis'; + axis: number; + direction?: 'positive' | 'negative' | 'both'; + threshold?: number; + gamepadId?: string; +} + +/** + * Union describing supported input bindings. + * Import: gameplay/inputManager.ts + */ +export type InputBinding = + | KeyboardBinding + | MouseBinding + | GamepadButtonBinding + | GamepadAxisBinding; + +/** + * Action definition used when creating the input manager. + * Use for: declaring action ids and default bindings. + * Import: gameplay/inputManager.ts + */ +export interface InputActionDefinition { + id: string; + bindings: ReadonlyArray; + type?: InputActionType; + deadzone?: number; +} + +/** + * Input manager configuration options. + * Use for: supplying action definitions and time sources. + * Import: gameplay/inputManager.ts + */ +export interface InputManagerOptions { + actions: ReadonlyArray; + getTime?: () => number; + defaultAxisThreshold?: number; +} + +/** + * Runtime state snapshot for an action. + * Import: gameplay/inputManager.ts + */ +export interface InputActionState { + id: string; + active: boolean; + value: number; + changedAt: number; + type: InputActionType; +} + +/** + * Keyboard event payload accepted by the manager. + * Import: gameplay/inputManager.ts + */ +export interface KeyInputEvent { + type: 'down' | 'up'; + key: string; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + time?: number; +} + +/** + * Pointer event payload accepted by the manager. + * Import: gameplay/inputManager.ts + */ +export interface PointerInputEvent { + type: 'down' | 'up'; + button: number; + time?: number; +} + +/** + * Gamepad button event payload accepted by the manager. + * Import: gameplay/inputManager.ts + */ +export interface GamepadButtonEvent { + type: 'down' | 'up'; + button: string | number; + value?: number; + gamepadId?: string; + time?: number; +} + +/** + * Gamepad axis event payload accepted by the manager. + * Import: gameplay/inputManager.ts + */ +export interface GamepadAxisEvent { + axis: number; + value: number; + gamepadId?: string; + time?: number; +} + +/** + * Input manager controller API. + * Use for: handling events and querying remappable action states. + * Import: gameplay/inputManager.ts + */ +export interface InputManager { + handleKeyEvent(event: KeyInputEvent): boolean; + handlePointerEvent(event: PointerInputEvent): boolean; + handleGamepadButton(event: GamepadButtonEvent): boolean; + handleGamepadAxis(event: GamepadAxisEvent): boolean; + isActive(actionId: string): boolean; + getValue(actionId: string): number; + getState(actionId: string): InputActionState | undefined; + getActions(): ReadonlyArray; + getBindings(actionId: string): ReadonlyArray; + setBindings(actionId: string, bindings: ReadonlyArray): void; + reset(): void; +} + +/** + * Creates an input manager for keyboard, mouse, and gamepad remapping. + * Use for: abstracting input handling across devices. + * Import: gameplay/inputManager.ts + */ +export function createInputManager(options: InputManagerOptions): InputManager; + +/** + * Metadata describing a saved slot entry. + * Import: gameplay/saveManager.ts + */ +export interface SaveSlotMetadata { + slotId: string; + checksum: string; + updatedAt: number; + size: number; + version?: number; +} + +/** + * Result returned after saving a slot. + * Import: gameplay/saveManager.ts + */ +export interface SaveResult { + metadata: SaveSlotMetadata; + overwritten?: SaveSlotMetadata; + evicted?: ReadonlyArray; +} + +/** + * Load failure reasons. + * Import: gameplay/saveManager.ts + */ +export type LoadError = 'not-found' | 'corrupted' | 'parse-error'; + +/** + * Result returned when loading a slot. + * Import: gameplay/saveManager.ts + */ +export interface LoadResult { + ok: boolean; + slotId: string; + data?: T; + metadata?: SaveSlotMetadata; + error?: LoadError; +} + +/** + * Minimal storage adapter contract for persistence. + * Import: gameplay/saveManager.ts + */ +export interface SaveStorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + keys(): Iterable; +} + +/** + * Configuration options for the save manager. + * Import: gameplay/saveManager.ts + */ +export interface SaveManagerOptions { + prefix?: string; + storage?: SaveStorageAdapter; + serializer?: (data: T) => string; + deserializer?: (raw: string) => T; + checksum?: (raw: string) => string; + getTime?: () => number; + version?: number; + maxSlots?: number; +} + +/** + * Save manager API for slot-based persistence. + * Import: gameplay/saveManager.ts + */ +export interface SaveManager { + save(slotId: string, data: T, time?: number): SaveResult; + load(slotId: string): LoadResult; + delete(slotId: string): SaveSlotMetadata | null; + list(): ReadonlyArray; + get(slotId: string): SaveSlotMetadata | null; + verify(slotId: string): boolean; + clear(): ReadonlyArray; + getStorage(): SaveStorageAdapter; +} + +/** + * Creates an in-memory storage adapter useful for tests. + * Import: gameplay/saveManager.ts + */ +export function createMemorySaveStorage(): SaveStorageAdapter; + +/** + * Creates a save manager with slot metadata and checksum verification. + * Import: gameplay/saveManager.ts + */ +export function createSaveManager(options: SaveManagerOptions): SaveManager; + +/** + * Screen transition configuration. + * Use for: defining fade-in/out durations and easing. + * Import: gameplay/screenTransitions.ts + */ +export interface ScreenTransitionOptions { + durationIn: number; + durationOut: number; + hold?: number; + easingIn?: (t: number) => number; + easingOut?: (t: number) => number; +} + +/** + * Snapshot of transition progress. + * Import: gameplay/screenTransitions.ts + */ +export interface ScreenTransitionState { + phase: 'idle' | 'in' | 'hold' | 'out' | 'completed'; + progress: number; + value: number; + elapsed: number; + totalDuration: number; +} + +/** + * Controller for screen transitions. + * Import: gameplay/screenTransitions.ts + */ +export interface ScreenTransitionController { + start(): void; + update(delta: number): ScreenTransitionState; + getState(): ScreenTransitionState; + reset(): void; + isActive(): boolean; + isCompleted(): boolean; +} + +/** + * Creates a transition controller for fades/letterboxing/wipes. + * Import: gameplay/screenTransitions.ts + */ +export function createScreenTransition(options: ScreenTransitionOptions): ScreenTransitionController; + +/** + * Fade effect helper returning current opacity. + * Import: gameplay/screenTransitions.ts + */ +export interface FadeResult { + opacity: number; +} +export function computeFade(state: ScreenTransitionState): FadeResult; + +/** + * Horizontal wipe effect helper. + * Import: gameplay/screenTransitions.ts + */ +export interface WipeResult { + offset: number; + direction: 'left' | 'right'; +} +export function computeHorizontalWipe(state: ScreenTransitionState, direction?: 'left' | 'right'): WipeResult; + +/** + * Letterbox effect helper returning bar size. + * Import: gameplay/screenTransitions.ts + */ +export interface LetterboxResult { + barSize: number; +} +export function computeLetterbox(state: ScreenTransitionState, maxBar: number): LetterboxResult; + +/** + * FSM state definition. + * Import: ai/fsm.ts + */ +export interface StateDefinition { + id: string; + onEnter?: (context: TContext, event?: TEvent) => void; + onExit?: (context: TContext, event?: TEvent) => void; + onUpdate?: (context: TContext, delta: number) => void; +} + +/** + * FSM transition definition. + * Import: ai/fsm.ts + */ +export interface TransitionDefinition { + from: string; + to: string; + event: string; + condition?: (context: TContext, event: TEvent) => boolean; + action?: (context: TContext, event: TEvent) => void; +} + +/** + * Finite state machine configuration options. + * Import: ai/fsm.ts + */ +export interface FSMOptions { + initial: string; + context: TContext; + states: ReadonlyArray>; + transitions?: ReadonlyArray>; +} + +/** + * Finite state machine controller API. + * Import: ai/fsm.ts + */ +export interface FSMController { + send(eventName: string, payload: TEvent): boolean; + update(delta: number): void; + getState(): string; + getContext(): TContext; + reset(stateId?: string): void; +} + +/** + * Creates a finite state machine. + * Import: ai/fsm.ts + */ +export function createFSM(options: FSMOptions): FSMController; + +/** + * Parent selection function signature for the GA helper. + * Import: ai/genetic.ts + */ +export type ParentSelector = ( + population: ReadonlyArray, + fitnesses: ReadonlyArray, + random: () => number, + maximize: boolean +) => number; + +/** + * Genetic algorithm configuration options. + * Import: ai/genetic.ts + */ +export interface GeneticAlgorithmOptions { + population: ReadonlyArray; + fitness: (individual: T) => number; + mutate: (individual: T, random: () => number) => T; + crossover?: (a: T, b: T, random: () => number) => T; + selection?: ParentSelector; + elitism?: number; + maximize?: boolean; + random?: () => number; +} + +/** + * Genetic algorithm controller. + * Import: ai/genetic.ts + */ +export interface GeneticAlgorithmController { + step(): void; + run(generations: number): void; + getPopulation(): ReadonlyArray; + getBest(): { individual: T; fitness: number }; + getGeneration(): number; +} + +/** + * Creates a genetic algorithm helper for evolutionary optimisation. + * Import: ai/genetic.ts + */ +export function createGeneticAlgorithm( + options: GeneticAlgorithmOptions +): GeneticAlgorithmController; + +/** + * Influence map source definition. + * Import: ai/influenceMap.ts + */ +export interface InfluenceSource { + position: { x: number; y: number }; + strength: number; + radius?: number; + falloff?: 'linear' | 'inverse' | 'constant'; +} + +/** + * Influence map configuration options. + * Import: ai/influenceMap.ts + */ +export interface InfluenceMapOptions { + width: number; + height: number; + cellSize?: number; + sources: ReadonlyArray; + obstacles?: (x: number, y: number) => boolean; + /** Optional [0, 1] smoothing factor applied after contributions. */ + decay?: number; +} + +/** + * Influence map result payload. + * Import: ai/influenceMap.ts + */ +export interface InfluenceMapResult { + width: number; + height: number; + cellSize: number; + values: Float32Array; +} + +/** + * Computes an influence map for AI positioning. + * Import: ai/influenceMap.ts + */ +export function computeInfluenceMap(options: InfluenceMapOptions): InfluenceMapResult; /** * Item insertion payload used by the inventory controller. * Use for: adding items with quantity and metadata. @@ -1919,6 +2569,47 @@ export function binarySearch( compareFn?: (a: T, b: T) => number ): number; +/** + * Knuth–Morris–Pratt substring search. + * Use for: searching within large texts with linear complexity. + * Performance: O(n + m) where n = text length, m = pattern length. + * Import: search/kmp.ts + */ +export interface KMPSearchOptions { + text: string; + pattern: string; + caseSensitive?: boolean; +} +export function kmpSearch(options: KMPSearchOptions): number[]; + +/** + * Rabin–Karp substring search supporting multiple patterns. + * Use for: scanning texts for multiple signatures with rolling hashes. + * Performance: O(n + m) expected, depending on hash collisions. + * Import: search/rabinKarp.ts + */ +export interface RabinKarpOptions { + text: string; + patterns: ReadonlyArray; + prime?: number; + base?: number; + caseSensitive?: boolean; +} +export function rabinKarp(options: RabinKarpOptions): Record; + +/** + * Boyer–Moore substring search with bad-character and good-suffix heuristics. + * Use for: efficient single-pattern searches on large texts. + * Performance: O(n) average case. + * Import: search/boyerMoore.ts + */ +export interface BoyerMooreOptions { + text: string; + pattern: string; + caseSensitive?: boolean; +} +export function boyerMooreSearch(options: BoyerMooreOptions): number[]; + /** * Levenshtein edit distance between two strings. * Use for: spellcheck, similarity scoring, diff tools. @@ -1927,6 +2618,41 @@ export function binarySearch( */ export function levenshteinDistance(a: string, b: string): number; +/** + * Suffix array construction with LCP computation. + * Use for: substring queries, suffix automata, indexing. + * Performance: O(n log n) for suffix array, O(n) for LCP. + * Import: search/suffixArray.ts + */ +export interface SuffixArrayOptions { + text: string; + caseSensitive?: boolean; +} +export interface SuffixArrayResult { + suffixArray: number[]; + lcpArray: number[]; +} +export function buildSuffixArray(options: SuffixArrayOptions): SuffixArrayResult; + +/** + * Longest common subsequence helpers. + * Import: search/lcs.ts + */ +export interface LCSOptions { + a: string; + b: string; +} +export interface LCSResult { + length: number; + sequence: string; +} +export interface DiffOp { + type: 'equal' | 'insert' | 'delete'; + value: string; +} +export function longestCommonSubsequence(options: LCSOptions): LCSResult; +export function diffStrings(options: LCSOptions): DiffOp[]; + // ============================================================================ // 📊 DATA TOOLS // ============================================================================ @@ -1962,6 +2688,60 @@ export type JsonDiffOperation = | { op: 'replace'; path: JsonPathSegment[]; value: JsonValue }; export function diffJson(previous: JsonValue, next: JsonValue): JsonDiffOperation[]; export function applyJsonDiff(value: T, diff: JsonDiffOperation[]): JsonValue; +export interface DiffJsonAdvancedOptions { + ignoreKeys?: ReadonlyArray; + pathFilter?: (path: JsonPathSegment[]) => boolean; +} +export function diffJsonAdvanced( + previous: JsonValue, + next: JsonValue, + options?: DiffJsonAdvancedOptions +): JsonDiffOperation[]; + +/** + * Flattens nested structures into key/value pairs. + * Use for: serialising nested configs, diffing deeply nested settings. + * Import: data/flatten.ts + */ +export interface FlattenOptions { + delimiter?: string; +} +export function flatten(value: unknown, options?: FlattenOptions): Record; + +/** + * Expands flattened key/value pairs back into nested structures. + * Use for: restoring configuration objects, merging patches. + * Import: data/flatten.ts + */ +export interface UnflattenOptions { + delimiter?: string; +} +export function unflatten(entries: Record, options?: UnflattenOptions): unknown; + +/** + * Paginates arrays with metadata describing the slice. + * Use for: client-side paging, infinite scroll, batching exports. + * Performance: O(pageSize) for slicing. + * Import: data/pagination.ts + */ +export interface PaginateOptions { + items: ReadonlyArray; + page: number; + pageSize: number; +} +export interface PaginationMetadata { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; +} +export interface PaginationResult { + items: T[]; + metadata: PaginationMetadata; +} +export function paginate(options: PaginateOptions): PaginationResult; /** * Deep clone structured data. diff --git a/docs/list.md b/docs/list.md index 7f795c2..7e5b039 100644 --- a/docs/list.md +++ b/docs/list.md @@ -73,7 +73,7 @@ Quest/Dialog System - Story progression and missions Save/Load System - Game state persistence 2D Lighting System - Dynamic lights and shadows Wave Spawner - Enemy wave management -Sound Manager - Audio playback with pooling +Sound Manager - Channel limiting and priority playback Input Manager - Keyboard, mouse, gamepad handling Screen Transitions - Fade, slide effects diff --git a/examples/fsm.ts b/examples/fsm.ts new file mode 100644 index 0000000..c13c3b9 --- /dev/null +++ b/examples/fsm.ts @@ -0,0 +1,49 @@ +import { createFSM } from '../src/index.js'; + +interface Context { + mood: string; +} + +type Event = + | { type: 'greet' } + | { type: 'aggravate' } + | { type: 'calm' }; + +const fsm = createFSM({ + context: { mood: 'neutral' }, + initial: 'idle', + states: [ + { + id: 'idle', + onEnter: (ctx) => { + ctx.mood = 'neutral'; + }, + }, + { + id: 'friendly', + onEnter: (ctx) => { + ctx.mood = 'happy'; + }, + }, + { + id: 'angry', + onEnter: (ctx) => { + ctx.mood = 'angry'; + }, + }, + ], + transitions: [ + { from: 'idle', to: 'friendly', event: 'greet' }, + { from: 'friendly', to: 'angry', event: 'aggravate' }, + { from: 'angry', to: 'friendly', event: 'calm' }, + { from: 'friendly', to: 'idle', event: 'calm' }, + ], +}); + +console.log('Initial state:', fsm.getState(), fsm.getContext().mood); +fsm.send('greet', { type: 'greet' }); +console.log('After greet:', fsm.getState(), fsm.getContext().mood); +fsm.send('aggravate', { type: 'aggravate' }); +console.log('After aggravate:', fsm.getState(), fsm.getContext().mood); +fsm.send('calm', { type: 'calm' }); +console.log('After calm:', fsm.getState(), fsm.getContext().mood); diff --git a/examples/genetic.ts b/examples/genetic.ts new file mode 100644 index 0000000..6c69c15 --- /dev/null +++ b/examples/genetic.ts @@ -0,0 +1,62 @@ +import { createGeneticAlgorithm } from '../src/index.js'; + +const TARGET = 'HELLO'; + +type Individual = string; + +const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +let randomIndex = 0; +const sequence = [0.2, 0.8, 0.5, 0.3, 0.9, 0.1, 0.7, 0.4, 0.6, 0.2]; +const seededRandom = () => { + const value = sequence[randomIndex % sequence.length]; + randomIndex += 1; + return value; +}; + +const initialPopulation: Individual[] = Array.from({ length: 5 }, () => randomIndividual(seededRandom)); + +const ga = createGeneticAlgorithm({ + population: initialPopulation, + fitness: computeFitness, + mutate: (individual, rand) => mutateIndividual(individual, rand), + crossover: (a, b, rand) => crossoverIndividuals(a, b, rand), + elitism: 1, + random: seededRandom, +}); + +console.log('Generation', ga.getGeneration(), 'best', ga.getBest()); +ga.run(5); +const best = ga.getBest(); +console.log('Generation', ga.getGeneration(), 'best individual', best.individual, 'fitness', best.fitness); + +function computeFitness(individual: Individual): number { + let score = 0; + for (let i = 0; i < TARGET.length; i += 1) { + if (individual[i] === TARGET[i]) { + score += 1; + } + } + return score; +} + +function mutateIndividual(individual: Individual, rand: () => number): Individual { + const index = Math.floor(rand() * TARGET.length); + const chars = individual.split(''); + chars[index] = TARGET[index]; + return chars.join(''); +} + +function crossoverIndividuals(a: Individual, b: Individual, rand: () => number): Individual { + const midpoint = Math.floor(rand() * TARGET.length); + return a.slice(0, midpoint) + b.slice(midpoint); +} + +function randomIndividual(rand: () => number): Individual { + let result = ''; + for (let i = 0; i < TARGET.length; i += 1) { + const index = Math.floor(rand() * alphabet.length); + result += alphabet[index]; + } + return result; +} diff --git a/examples/influenceMap.ts b/examples/influenceMap.ts new file mode 100644 index 0000000..a818ed5 --- /dev/null +++ b/examples/influenceMap.ts @@ -0,0 +1,20 @@ +import { computeInfluenceMap } from '../src/index.js'; + +const map = computeInfluenceMap({ + width: 5, + height: 5, + cellSize: 1, + sources: [ + { position: { x: 1, y: 1 }, strength: 10, radius: 3, falloff: 'linear' }, + { position: { x: 4, y: 4 }, strength: -5, radius: 2, falloff: 'inverse' }, + ], + decay: 0.2, +}); + +for (let y = 0; y < map.height; y += 1) { + const row: string[] = []; + for (let x = 0; x < map.width; x += 1) { + row.push(map.values[y * map.width + x].toFixed(2)); + } + console.log(row.join(' ')); +} diff --git a/examples/inputManager.ts b/examples/inputManager.ts new file mode 100644 index 0000000..bb0105e --- /dev/null +++ b/examples/inputManager.ts @@ -0,0 +1,48 @@ +import { createInputManager } from '../src/index.js'; + +let now = 0; + +const input = createInputManager({ + getTime: () => now, + actions: [ + { + id: 'jump', + bindings: [ + { device: 'keyboard', key: 'Space' }, + { device: 'gamepad-button', button: 0 }, + ], + }, + { + id: 'fire', + bindings: [{ device: 'mouse', button: 0 }], + }, + { + id: 'move-horizontal', + type: 'analog', + bindings: [{ device: 'gamepad-axis', axis: 0, direction: 'both', threshold: 0.2 }], + }, + ], +}); + +function advance(time: number): void { + now += time; +} + +input.handleKeyEvent({ type: 'down', key: 'Space' }); +console.log('Jump active:', input.isActive('jump')); +input.handleKeyEvent({ type: 'up', key: 'Space' }); + +input.handlePointerEvent({ type: 'down', button: 0 }); +console.log('Fire active:', input.isActive('fire')); +input.handlePointerEvent({ type: 'up', button: 0 }); + +input.handleGamepadAxis({ axis: 0, value: 0.1 }); +console.log('Move horizontal value (deadzone):', input.getValue('move-horizontal')); + +input.handleGamepadAxis({ axis: 0, value: 0.6 }); +console.log('Move horizontal value:', input.getValue('move-horizontal')); + +advance(0.5); +input.setBindings('jump', [{ device: 'keyboard', key: 'KeyZ' }]); +input.handleKeyEvent({ type: 'down', key: 'KeyZ' }); +console.log('Jump remapped active:', input.isActive('jump')); diff --git a/examples/jsonDiff.ts b/examples/jsonDiff.ts index 4aef8a6..62b8348 100644 --- a/examples/jsonDiff.ts +++ b/examples/jsonDiff.ts @@ -1,4 +1,4 @@ -import { applyJsonDiff, diffJson } from '../src/index.js'; +import { applyJsonDiff, diffJson, diffJsonAdvanced, flatten, unflatten } from '../src/index.js'; const previous = { status: 'idle', jobs: ['ingest', 'transform'] }; const next = { status: 'running', jobs: ['ingest', 'transform', 'export'] }; @@ -8,3 +8,13 @@ const updated = applyJsonDiff(previous, patch); console.log(patch); console.log(updated); + +const flattened = flatten(updated); +console.log('Flattened:', flattened); +const reconstructed = unflatten(flattened); +console.log('Reconstructed:', reconstructed); + +const selectivePatch = diffJsonAdvanced(previous, next, { + ignoreKeys: ['jobs'], +}); +console.log('Selective patch (ignore jobs):', selectivePatch); diff --git a/examples/pagination.ts b/examples/pagination.ts new file mode 100644 index 0000000..18a2450 --- /dev/null +++ b/examples/pagination.ts @@ -0,0 +1,8 @@ +import { paginate } from '../src/index.js'; + +const items = Array.from({ length: 23 }, (_, index) => ({ id: index + 1 })); + +const { items: pageItems, metadata } = paginate({ items, page: 2, pageSize: 5 }); + +console.log('Page metadata:', metadata); +console.log('Page items:', pageItems); diff --git a/examples/saveManager.ts b/examples/saveManager.ts new file mode 100644 index 0000000..8590b96 --- /dev/null +++ b/examples/saveManager.ts @@ -0,0 +1,39 @@ +import { createSaveManager } from '../src/index.js'; + +interface PlayerState { + level: number; + coins: number; +} + +let now = 0; + +const saves = createSaveManager({ + prefix: 'campaign', + version: 1, + getTime: () => now, + maxSlots: 2, +}); + +function advance(seconds: number): void { + now += seconds; +} + +console.log('Saving slot-1'); +saves.save('slot-1', { level: 5, coins: 150 }); +advance(10); + +console.log('Saving slot-2'); +saves.save('slot-2', { level: 7, coins: 230 }); +advance(5); + +console.log('Overwriting slot-1'); +saves.save('slot-1', { level: 8, coins: 310 }); + +for (const metadata of saves.list()) { + console.log('Slot:', metadata.slotId, 'level size:', metadata.size, 'updated:', metadata.updatedAt); +} + +const loaded = saves.load('slot-1'); +if (loaded.ok) { + console.log('Loaded slot-1:', loaded.data); +} diff --git a/examples/screenTransitions.ts b/examples/screenTransitions.ts new file mode 100644 index 0000000..da5abce --- /dev/null +++ b/examples/screenTransitions.ts @@ -0,0 +1,17 @@ +import { computeFade, computeHorizontalWipe, computeLetterbox, createScreenTransition } from '../src/index.js'; + +const transition = createScreenTransition({ + durationIn: 1, + hold: 0.5, + durationOut: 1, +}); + +transition.start(); + +for (let step = 0; step < 6; step += 1) { + const state = transition.update(0.5); + const fade = computeFade(state); + const wipe = computeHorizontalWipe(state, 'left'); + const letterbox = computeLetterbox(state, 100); + console.log(`t=${state.elapsed.toFixed(1)} phase=${state.phase} fade=${fade.opacity.toFixed(2)} wipe=${wipe.offset.toFixed(2)} bars=${letterbox.barSize.toFixed(1)}`); +} diff --git a/examples/search.ts b/examples/search.ts index 7c406de..34e92ec 100644 --- a/examples/search.ts +++ b/examples/search.ts @@ -1,4 +1,16 @@ -import { binarySearch, fuzzyScore, fuzzySearch, Trie, levenshteinDistance } from '../src/index.js'; +import { + binarySearch, + fuzzyScore, + fuzzySearch, + Trie, + levenshteinDistance, + kmpSearch, + rabinKarp, + boyerMooreSearch, + buildSuffixArray, + longestCommonSubsequence, + diffStrings, +} from '../src/index.js'; const items = ['alpha', 'beta', 'delta', 'epsilon', 'gamma']; const matches = fuzzySearch('alp', items); @@ -12,3 +24,22 @@ console.log('Trie suggestions for "ga":', trie.startsWith('ga')); const sorted = [...items].sort(); console.log('Binary search for "delta":', binarySearch(sorted, 'delta')); console.log('Levenshtein distance between "graph" and "giraffe":', levenshteinDistance('graph', 'giraffe')); + +const occurrences = kmpSearch({ text: 'abracadabra', pattern: 'abra' }); +console.log('KMP matches for "abra" in "abracadabra":', occurrences); + +const multiMatches = rabinKarp({ text: 'mississippi', patterns: ['issi', 'ppi'] }); +console.log('Rabin–Karp matches:', multiMatches); + +const bmMatches = boyerMooreSearch({ text: 'here is a simple example', pattern: 'example' }); +console.log('Boyer–Moore matches for "example":', bmMatches); + +const suffixResult = buildSuffixArray({ text: 'banana' }); +console.log('Suffix array for banana:', suffixResult.suffixArray); +console.log('LCP array for banana:', suffixResult.lcpArray); + +const lcs = longestCommonSubsequence({ a: 'dynamic', b: 'programming' }); +console.log('LCS of dynamic/programming:', lcs); + +const diff = diffStrings({ a: 'kitten', b: 'sitting' }); +console.log('Diff between kitten and sitting:', diff); diff --git a/examples/soundManager.ts b/examples/soundManager.ts new file mode 100644 index 0000000..4fd650e --- /dev/null +++ b/examples/soundManager.ts @@ -0,0 +1,35 @@ +import { createSoundManager } from '../src/index.js'; + +let now = 0; + +const manager = createSoundManager({ + maxChannels: 3, + channelLimits: { + music: 1, + sfx: 2, + }, + getTime: () => now, +}); + +function advance(time: number): void { + now += time; + const finished = manager.update(); + if (finished.length > 0) { + console.log('Finished sounds:', finished.map((sound) => sound.soundId)); + } +} + +manager.play({ soundId: 'menu-music', channel: 'music', duration: 10, priority: 1 }); +manager.play({ soundId: 'ui-click', channel: 'sfx', duration: 0.5 }); +manager.play({ soundId: 'ui-hover', channel: 'sfx', duration: 0.5 }); + +// This request exceeds the SFX channel limit, so it only succeeds because of a higher priority. +const result = manager.play({ soundId: 'critical-warning', channel: 'sfx', duration: 2, priority: 5 }); +if (result.evicted) { + console.log('Preempted sound:', result.evicted.soundId); +} + +advance(1); +manager.play({ soundId: 'accept', channel: 'sfx', duration: 0.25 }); +advance(10); +console.log('Active sounds:', manager.getActive().map((sound) => sound.soundId)); diff --git a/examples/waveSpawner.ts b/examples/waveSpawner.ts new file mode 100644 index 0000000..ce1d584 --- /dev/null +++ b/examples/waveSpawner.ts @@ -0,0 +1,18 @@ +import { createWaveSpawner } from '../src/index.js'; + +const spawner = createWaveSpawner({ + loop: false, + waves: [ + { delay: 1, count: 3, interval: 0.5, template: { type: 'grunt', hp: 10 } }, + { delay: 2, count: 2, interval: 0.25, template: { type: 'archer', hp: 6 } }, + ], +}); + +let elapsed = 0; +while (!spawner.isFinished()) { + elapsed += 0.5; + const spawns = spawner.update(0.5); + if (spawns.length > 0) { + console.log(`t=${elapsed.toFixed(1)}s`, spawns); + } +} diff --git a/src/ai/fsm.ts b/src/ai/fsm.ts new file mode 100644 index 0000000..9fed5eb --- /dev/null +++ b/src/ai/fsm.ts @@ -0,0 +1,131 @@ +export interface StateDefinition { + id: string; + onEnter?: (context: TContext, event?: TEvent) => void; + onExit?: (context: TContext, event?: TEvent) => void; + onUpdate?: (context: TContext, delta: number) => void; +} + +export interface TransitionDefinition { + from: string; + to: string; + event: string; + condition?: (context: TContext, event: TEvent) => boolean; + action?: (context: TContext, event: TEvent) => void; +} + +export interface FSMOptions { + initial: string; + context: TContext; + states: ReadonlyArray>; + transitions?: ReadonlyArray>; +} + +export interface FSMController { + send(eventName: string, payload: TEvent): boolean; + update(delta: number): void; + getState(): string; + getContext(): TContext; + reset(stateId?: string): void; +} + +export function createFSM(options: FSMOptions): FSMController { + validateOptions(options); + + const stateMap = new Map>(); + const transitions = new Map[]>(); + + for (const state of options.states) { + if (stateMap.has(state.id)) { + throw new Error(`Duplicate state id: ${state.id}`); + } + stateMap.set(state.id, state); + } + + if (options.transitions) { + for (const transition of options.transitions) { + if (!stateMap.has(transition.from)) { + throw new Error(`Transition references unknown state: ${transition.from}`); + } + if (!stateMap.has(transition.to)) { + throw new Error(`Transition references unknown state: ${transition.to}`); + } + const list = transitions.get(transition.event) ?? []; + list.push(transition); + transitions.set(transition.event, list); + } + } + + const context = options.context; + let currentState = requireState(stateMap, options.initial); + currentState.onEnter?.(context); + + function send(eventName: string, payload: TEvent): boolean { + const candidates = transitions.get(eventName); + if (!candidates) { + return false; + } + for (const transition of candidates) { + if (transition.from !== currentState.id) { + continue; + } + if (transition.condition && !transition.condition(context, payload)) { + continue; + } + const nextState = requireState(stateMap, transition.to); + currentState.onExit?.(context, payload); + transition.action?.(context, payload); + currentState = nextState; + currentState.onEnter?.(context, payload); + return true; + } + return false; + } + + function update(delta: number): void { + assertNonNegative(delta, 'delta'); + currentState.onUpdate?.(context, delta); + } + + function reset(stateId?: string): void { + const target = stateId ?? options.initial; + currentState = requireState(stateMap, target); + currentState.onEnter?.(context); + } + + return { + send, + update, + getState: () => currentState.id, + getContext: () => context, + reset, + }; +} + +function requireState( + map: Map>, + id: string +): StateDefinition { + const state = map.get(id); + if (!state) { + throw new Error(`Unknown state: ${id}`); + } + return state; +} + +function validateOptions(options: FSMOptions): void { + if (!options.initial || typeof options.initial !== 'string') { + throw new Error('initial state must be provided.'); + } + if (!Array.isArray(options.states) || options.states.length === 0) { + throw new Error('states must contain at least one entry.'); + } + if (!options.states.some((state: StateDefinition) => state.id === options.initial)) { + throw new Error(`Unknown initial state: ${options.initial}`); + } +} + +function assertNonNegative(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a non-negative number.`); + } +} diff --git a/src/ai/genetic.ts b/src/ai/genetic.ts new file mode 100644 index 0000000..d27088f --- /dev/null +++ b/src/ai/genetic.ts @@ -0,0 +1,135 @@ +interface Evaluated { + individual: T; + fitness: number; +} + +export type ParentSelector = ( + population: ReadonlyArray, + fitnesses: ReadonlyArray, + random: () => number, + maximize: boolean +) => number; + +export interface GeneticAlgorithmOptions { + population: ReadonlyArray; + fitness: (individual: T) => number; + mutate: (individual: T, random: () => number) => T; + crossover?: (a: T, b: T, random: () => number) => T; + selection?: ParentSelector; + elitism?: number; + maximize?: boolean; + random?: () => number; +} + +export interface GeneticAlgorithmController { + step(): void; + run(generations: number): void; + getPopulation(): ReadonlyArray; + getBest(): Evaluated; + getGeneration(): number; +} + +export function createGeneticAlgorithm(options: GeneticAlgorithmOptions): GeneticAlgorithmController { + validateOptions(options); + + const random = options.random ?? Math.random; + const selector = options.selection ?? tournamentSelection; + const maximize = options.maximize ?? true; + const elitism = options.elitism ?? 0; + const size = options.population.length; + const mutate = options.mutate; + const crossover = options.crossover; + const fitness = options.fitness; + + let generation = 0; + let population = options.population.slice(); + let evaluated = evaluatePopulation(population, fitness); + + function step(): void { + generation += 1; + evaluated = evaluatePopulation(population, fitness); + const sorted = [...evaluated].sort((a, b) => compareFitness(a.fitness, b.fitness, maximize)); + + const newPopulation: T[] = []; + const eliteCount = Math.min(Math.max(elitism, 0), size); + for (let index = 0; index < eliteCount; index += 1) { + newPopulation.push(sorted[index].individual); + } + + const fitnesses = evaluated.map((entry) => entry.fitness); + while (newPopulation.length < size) { + const parentA = population[selector(population, fitnesses, random, maximize)]; + const parentB = population[selector(population, fitnesses, random, maximize)]; + let child = crossover ? crossover(parentA, parentB, random) : parentA; + child = mutate(child, random); + newPopulation.push(child); + } + + population = newPopulation; + evaluated = evaluatePopulation(population, fitness); + } + + function run(generations: number): void { + if (!Number.isInteger(generations) || generations < 0) { + throw new Error('generations must be a non-negative integer.'); + } + for (let index = 0; index < generations; index += 1) { + step(); + } + } + + return { + step, + run, + getPopulation: () => population.slice(), + getBest: () => [...evaluated].sort((a, b) => compareFitness(a.fitness, b.fitness, maximize))[0], + getGeneration: () => generation, + }; +} + +function evaluatePopulation(population: ReadonlyArray, fitness: (individual: T) => number): Evaluated[] { + return population.map((individual) => ({ individual, fitness: fitness(individual) })); +} + +function compareFitness(a: number, b: number, maximize: boolean): number { + return maximize ? b - a : a - b; +} + +function tournamentSelection( + population: ReadonlyArray, + fitnesses: ReadonlyArray, + random: () => number, + maximize: boolean +): number { + const size = population.length; + const candidates = Math.min(3, size); + let bestIndex = randomIndex(size, random); + for (let i = 1; i < candidates; i += 1) { + const contender = randomIndex(size, random); + if (compareFitness(fitnesses[contender], fitnesses[bestIndex], maximize) < 0) { + continue; + } + bestIndex = contender; + } + return bestIndex; +} + +function randomIndex(size: number, random: () => number): number { + return Math.max(0, Math.min(size - 1, Math.floor(random() * size))); +} + +function validateOptions(options: GeneticAlgorithmOptions): void { + if (!Array.isArray(options.population) || options.population.length === 0) { + throw new Error('population must contain at least one individual.'); + } + if (typeof options.fitness !== 'function') { + throw new Error('fitness function is required.'); + } + if (typeof options.mutate !== 'function') { + throw new Error('mutate function is required.'); + } + if (options.elitism !== undefined && (!Number.isInteger(options.elitism) || options.elitism < 0)) { + throw new Error('elitism must be a non-negative integer.'); + } +} + diff --git a/src/ai/influenceMap.ts b/src/ai/influenceMap.ts new file mode 100644 index 0000000..299dccf --- /dev/null +++ b/src/ai/influenceMap.ts @@ -0,0 +1,161 @@ +export interface InfluenceSource { + position: { x: number; y: number }; + strength: number; + radius?: number; + falloff?: 'linear' | 'inverse' | 'constant'; +} + +export interface InfluenceMapOptions { + width: number; + height: number; + cellSize?: number; + sources: ReadonlyArray; + obstacles?: (x: number, y: number) => boolean; + decay?: number; +} + +export interface InfluenceMapResult { + width: number; + height: number; + cellSize: number; + values: Float32Array; +} + +export function computeInfluenceMap(options: InfluenceMapOptions): InfluenceMapResult { + validateOptions(options); + const cellSize = options.cellSize ?? 1; + const values = new Float32Array(options.width * options.height); + + const sources: ReadonlyArray = options.sources; + for (const source of sources) { + applySource(values, options, source, cellSize); + } + + if (options.decay !== undefined && options.decay > 0) { + applyDecay(values, options, options.decay); + } + + return { + width: options.width, + height: options.height, + cellSize, + values, + }; +} + +function applySource(values: Float32Array, options: InfluenceMapOptions, source: InfluenceSource, cellSize: number): void { + const radius = source.radius ?? Infinity; + const falloff = source.falloff ?? 'linear'; + const strength = source.strength; + const startX = Math.max(0, Math.floor((source.position.x - radius) / cellSize)); + const endX = Math.min(options.width - 1, Math.ceil((source.position.x + radius) / cellSize)); + const startY = Math.max(0, Math.floor((source.position.y - radius) / cellSize)); + const endY = Math.min(options.height - 1, Math.ceil((source.position.y + radius) / cellSize)); + + for (let y = startY; y <= endY; y += 1) { + for (let x = startX; x <= endX; x += 1) { + if (options.obstacles?.(x, y)) { + continue; + } + const distance = euclideanDistance(source.position, { x: x * cellSize + cellSize / 2, y: y * cellSize + cellSize / 2 }); + if (distance > radius) { + continue; + } + const contribution = computeContribution(strength, distance, radius, falloff); + values[y * options.width + x] += contribution; + } + } +} + +function applyDecay(values: Float32Array, options: InfluenceMapOptions, decay: number): void { + const width = options.width; + const height = options.height; + const newValues = values.slice(); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const idx = y * width + x; + let total = values[idx]; + let count = 1; + if (x > 0) { + total += values[idx - 1]; + count += 1; + } + if (x < width - 1) { + total += values[idx + 1]; + count += 1; + } + if (y > 0) { + total += values[idx - width]; + count += 1; + } + if (y < height - 1) { + total += values[idx + width]; + count += 1; + } + newValues[idx] = values[idx] * (1 - decay) + (total / count) * decay; + } + } + + values.set(newValues); +} + +function computeContribution(strength: number, distance: number, radius: number, falloff: 'linear' | 'inverse' | 'constant'): number { + if (distance === 0) { + return strength; + } + switch (falloff) { + case 'linear': + return strength * Math.max(0, 1 - distance / radius); + case 'inverse': + return strength / (distance * distance); + case 'constant': + return strength; + default: + return 0; + } +} + +function euclideanDistance(a: { x: number; y: number }, b: { x: number; y: number }): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.hypot(dx, dy); +} + +function validateOptions(options: InfluenceMapOptions): void { + if (!Number.isInteger(options.width) || options.width <= 0) { + throw new Error('width must be a positive integer.'); + } + if (!Number.isInteger(options.height) || options.height <= 0) { + throw new Error('height must be a positive integer.'); + } + if (!Array.isArray(options.sources) || options.sources.length === 0) { + throw new Error('sources must contain at least one source.'); + } + if (options.cellSize !== undefined && (typeof options.cellSize !== 'number' || !Number.isFinite(options.cellSize) || options.cellSize <= 0)) { + throw new Error('cellSize must be a positive number.'); + } + if (options.decay !== undefined) { + if (typeof options.decay !== 'number' || Number.isNaN(options.decay) || !Number.isFinite(options.decay)) { + throw new Error('decay must be a finite number.'); + } + if (options.decay < 0 || options.decay > 1) { + throw new Error('decay must be in the range [0, 1].'); + } + } + const sources: ReadonlyArray = options.sources; + for (const source of sources) { + const { position } = source; + if (!position || typeof position.x !== 'number' || typeof position.y !== 'number') { + throw new Error('Each source must have a numeric position.'); + } + if (typeof source.strength !== 'number' || Number.isNaN(source.strength) || !Number.isFinite(source.strength)) { + throw new Error('Each source must have a numeric strength.'); + } + if (source.radius !== undefined && (!Number.isFinite(source.radius) || source.radius <= 0)) { + throw new Error('Source radius must be positive when provided.'); + } + if (source.falloff && source.falloff !== 'linear' && source.falloff !== 'inverse' && source.falloff !== 'constant') { + throw new Error('Source falloff must be linear, inverse, or constant when provided.'); + } + } +} diff --git a/src/data/flatten.ts b/src/data/flatten.ts new file mode 100644 index 0000000..d6bf028 --- /dev/null +++ b/src/data/flatten.ts @@ -0,0 +1,131 @@ +export interface FlattenOptions { + delimiter?: string; +} + +export interface UnflattenOptions { + delimiter?: string; +} + +export function flatten(value: unknown, options: FlattenOptions = {}): Record { + const delimiter = options.delimiter ?? '.'; + const result: Record = {}; + + function walk(current: unknown, path: string[]): void { + if (isPlainObject(current)) { + for (const [key, nested] of Object.entries(current)) { + walk(nested, path.concat(key)); + } + return; + } + + if (Array.isArray(current)) { + current.forEach((item, index) => { + walk(item, path.concat(String(index))); + }); + return; + } + + const joined = path.join(delimiter); + result[joined] = current; + } + + walk(value, []); + return result; +} + +export function unflatten(entries: Record, options: UnflattenOptions = {}): unknown { + const delimiter = options.delimiter ?? '.'; + const root: Record = {}; + + for (const [compoundKey, value] of Object.entries(entries)) { + const segments = compoundKey.split(delimiter); + let current: Record | unknown[] = root; + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]; + const isLast = i === segments.length - 1; + if (isLast) { + assign(current, segment, value); + continue; + } + const nextSegment = segments[i + 1]; + const shouldBeArray = isNumeric(nextSegment); + const nextTarget = ensureChild(current, segment, shouldBeArray); + current = nextTarget; + } + } + + return convertArrays(root); +} + +function assign(target: Record | unknown[], key: string, value: unknown): void { + if (Array.isArray(target)) { + const index = Number(key); + target[index] = value; + } else { + target[key] = value; + } +} + +function ensureChild( + target: Record | unknown[], + key: string, + shouldBeArray: boolean +): Record | unknown[] { + if (Array.isArray(target)) { + const index = Number(key); + if (target[index] === undefined) { + target[index] = shouldBeArray ? [] : {}; + } + const next = target[index]; + if (shouldBeArray && !Array.isArray(next)) { + target[index] = []; + } else if (!shouldBeArray && !isPlainObject(next)) { + target[index] = {}; + } + return target[index] as Record | unknown[]; + } + + if (!(key in target)) { + target[key] = shouldBeArray ? [] : {}; + } + const next = target[key]; + if (shouldBeArray && !Array.isArray(next)) { + target[key] = []; + } else if (!shouldBeArray && !isPlainObject(next)) { + target[key] = {}; + } + return target[key] as Record | unknown[]; +} + +function convertArrays(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => convertArrays(item)); + } + if (!isPlainObject(value)) { + return value; + } + + const record: Record = value; + const keys = Object.keys(record); + const numericKeys = keys.every((key) => isNumeric(key)); + if (numericKeys) { + const array: unknown[] = []; + for (const key of keys.sort((a, b) => Number(a) - Number(b))) { + array[Number(key)] = convertArrays(record[key]); + } + return array; + } + const result: Record = {}; + for (const [key, nested] of Object.entries(record)) { + result[key] = convertArrays(nested); + } + return result; +} + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +function isNumeric(value: string): boolean { + return /^\d+$/.test(value); +} diff --git a/src/data/jsonDiff.ts b/src/data/jsonDiff.ts index e065357..24d1cf4 100644 --- a/src/data/jsonDiff.ts +++ b/src/data/jsonDiff.ts @@ -14,91 +14,99 @@ export type JsonDiffOperation = * Computes a structural diff between two JSON-compatible values. * Useful for: syncing cached state, generating patches, change feeds. */ -export function diffJson(previous: JsonValue, next: JsonValue): JsonDiffOperation[] { - const operations: JsonDiffOperation[] = []; - walkDiff(previous, next, [], operations); - return operations; +export interface DiffJsonAdvancedOptions { + ignoreKeys?: ReadonlyArray; + pathFilter?: (path: JsonPathSegment[]) => boolean; } -/** - * Applies a JSON diff to a value and returns a new structure. - * Useful for: reconstructing snapshots, applying remote patches, optimistic updates. - */ -export function applyJsonDiff(value: T, diff: JsonDiffOperation[]): JsonValue { - let result: JsonValue = deepClone(value); - for (const operation of diff) { - result = applyOperation(result, operation); - } - return result; +export function diffJson(previous: JsonValue, next: JsonValue): JsonDiffOperation[] { + return diffJsonAdvanced(previous, next, {}); } -function walkDiff( +export function diffJsonAdvanced( previous: JsonValue, next: JsonValue, - path: JsonPathSegment[], - operations: JsonDiffOperation[] -): void { - if (Object.is(previous, next)) { - return; - } + options: DiffJsonAdvancedOptions = {} +): JsonDiffOperation[] { + const ignoreSet = new Set(options.ignoreKeys ?? []); + const operations: JsonDiffOperation[] = []; + walkDiff(previous, next, [], operations); + return operations; - if (Array.isArray(previous) && Array.isArray(next)) { - diffArray(previous, next, path, operations); - return; - } + function walkDiff( + prev: JsonValue, + nxt: JsonValue, + path: JsonPathSegment[], + ops: JsonDiffOperation[] + ): void { + if (options.pathFilter && !options.pathFilter(path)) { + return; + } - if (isPlainObject(previous) && isPlainObject(next)) { - diffObject(previous, next, path, operations); - return; - } + if (Object.is(prev, nxt)) { + return; + } - operations.push({ op: 'replace', path, value: deepClone(next) }); -} + if (Array.isArray(prev) && Array.isArray(nxt)) { + diffArray(prev, nxt, path, ops); + return; + } -function diffArray( - previous: JsonValue[], - next: JsonValue[], - path: JsonPathSegment[], - operations: JsonDiffOperation[] -): void { - const minLength = Math.min(previous.length, next.length); + if (isPlainObject(prev) && isPlainObject(nxt)) { + diffObject(prev, nxt, path, ops); + return; + } - for (let index = 0; index < minLength; index += 1) { - const prevValue = previous[index]; - const nextValue = next[index]; - walkDiff(prevValue, nextValue, [...path, index], operations); + ops.push({ op: 'replace', path, value: deepClone(nxt) }); } - for (let index = previous.length - 1; index >= next.length; index -= 1) { - operations.push({ op: 'remove', path: [...path, index] }); - } + function diffArray(prev: JsonValue[], nxt: JsonValue[], path: JsonPathSegment[], ops: JsonDiffOperation[]): void { + const minLength = Math.min(prev.length, nxt.length); + + for (let index = 0; index < minLength; index += 1) { + const prevValue = prev[index]; + const nextValue = nxt[index]; + walkDiff(prevValue, nextValue, [...path, index], ops); + } + + for (let index = prev.length - 1; index >= nxt.length; index -= 1) { + ops.push({ op: 'remove', path: [...path, index] }); + } - for (let index = minLength; index < next.length; index += 1) { - operations.push({ op: 'add', path: [...path, index], value: deepClone(next[index]) }); + for (let index = minLength; index < nxt.length; index += 1) { + ops.push({ op: 'add', path: [...path, index], value: deepClone(nxt[index]) }); + } } -} -function diffObject( - previous: JsonObject, - next: JsonObject, - path: JsonPathSegment[], - operations: JsonDiffOperation[] -): void { - const previousKeys = new Set(Object.keys(previous)); - const nextKeys = new Set(Object.keys(next)); - - for (const key of nextKeys) { - if (previousKeys.has(key)) { - walkDiff(previous[key], next[key], [...path, key], operations); - previousKeys.delete(key); - } else { - operations.push({ op: 'add', path: [...path, key], value: deepClone(next[key]) }); + function diffObject(prev: JsonObject, nxt: JsonObject, path: JsonPathSegment[], ops: JsonDiffOperation[]): void { + const prevKeys = new Set(Object.keys(prev).filter((key) => !ignoreSet.has(key))); + const nextKeys = new Set(Object.keys(nxt).filter((key) => !ignoreSet.has(key))); + + for (const key of nextKeys) { + if (prevKeys.has(key)) { + walkDiff(prev[key], nxt[key], [...path, key], ops); + prevKeys.delete(key); + } else { + ops.push({ op: 'add', path: [...path, key], value: deepClone(nxt[key]) }); + } + } + + for (const key of prevKeys) { + ops.push({ op: 'remove', path: [...path, key] }); } } +} - for (const key of previousKeys) { - operations.push({ op: 'remove', path: [...path, key] }); +/** + * Applies a JSON diff to a value and returns a new structure. + * Useful for: reconstructing snapshots, applying remote patches, optimistic updates. + */ +export function applyJsonDiff(value: T, diff: JsonDiffOperation[]): JsonValue { + let result: JsonValue = deepClone(value); + for (const operation of diff) { + result = applyOperation(result, operation); } + return result; } function applyOperation(root: JsonValue, operation: JsonDiffOperation): JsonValue { diff --git a/src/data/pagination.ts b/src/data/pagination.ts new file mode 100644 index 0000000..00742ed --- /dev/null +++ b/src/data/pagination.ts @@ -0,0 +1,55 @@ +export interface PaginateOptions { + items: ReadonlyArray; + page: number; + pageSize: number; +} + +export interface PaginationMetadata { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + hasPrevious: boolean; + hasNext: boolean; +} + +export interface PaginationResult { + items: T[]; + metadata: PaginationMetadata; +} + +export function paginate(options: PaginateOptions): PaginationResult { + validateOptions(options); + const totalItems = options.items.length; + const totalPages = Math.max(1, Math.ceil(totalItems / options.pageSize)); + const page = Math.min(Math.max(1, options.page), totalPages); + + const start = (page - 1) * options.pageSize; + const end = Math.min(start + options.pageSize, totalItems); + const slice = options.items.slice(start, end); + + return { + items: slice, + metadata: { + page, + pageSize: options.pageSize, + totalItems, + totalPages, + hasPrevious: page > 1, + hasNext: page < totalPages, + }, + }; +} + +function validateOptions(options: PaginateOptions): void { + if (!Array.isArray(options.items)) { + throw new TypeError('items must be an array'); + } + if (!Number.isInteger(options.pageSize) || options.pageSize <= 0) { + throw new Error('pageSize must be a positive integer'); + } + if (!Number.isInteger(options.page) || options.page <= 0) { + throw new Error('page must be a positive integer'); + } +} + diff --git a/src/gameplay/inputManager.ts b/src/gameplay/inputManager.ts new file mode 100644 index 0000000..5e4e0c1 --- /dev/null +++ b/src/gameplay/inputManager.ts @@ -0,0 +1,483 @@ +const DEFAULT_AXIS_THRESHOLD = 0.4; +const DEFAULT_ACTION_TYPE: InputActionType = 'digital'; +const DEFAULT_GAMEPAD_ID = 'default'; + +type ModifierFlags = { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +}; + +export type InputActionType = 'digital' | 'analog'; + +export interface KeyboardBinding extends ModifierFlags { + device: 'keyboard'; + key: string; +} + +export interface MouseBinding { + device: 'mouse'; + button: number; +} + +export interface GamepadButtonBinding { + device: 'gamepad-button'; + button: string | number; + gamepadId?: string; +} + +export interface GamepadAxisBinding { + device: 'gamepad-axis'; + axis: number; + direction?: 'positive' | 'negative' | 'both'; + threshold?: number; + gamepadId?: string; +} + +export type InputBinding = + | KeyboardBinding + | MouseBinding + | GamepadButtonBinding + | GamepadAxisBinding; + +export interface InputActionDefinition { + id: string; + bindings: ReadonlyArray; + type?: InputActionType; + deadzone?: number; +} + +export interface InputManagerOptions { + actions: ReadonlyArray; + getTime?: () => number; + defaultAxisThreshold?: number; +} + +export interface InputActionState { + id: string; + active: boolean; + value: number; + changedAt: number; + type: InputActionType; +} + +export interface KeyInputEvent extends ModifierFlags { + type: 'down' | 'up'; + key: string; + time?: number; +} + +export interface PointerInputEvent { + type: 'down' | 'up'; + button: number; + time?: number; +} + +export interface GamepadButtonEvent { + type: 'down' | 'up'; + button: string | number; + value?: number; + gamepadId?: string; + time?: number; +} + +export interface GamepadAxisEvent { + axis: number; + value: number; + gamepadId?: string; + time?: number; +} + +export interface InputManager { + handleKeyEvent(event: KeyInputEvent): boolean; + handlePointerEvent(event: PointerInputEvent): boolean; + handleGamepadButton(event: GamepadButtonEvent): boolean; + handleGamepadAxis(event: GamepadAxisEvent): boolean; + isActive(actionId: string): boolean; + getValue(actionId: string): number; + getState(actionId: string): InputActionState | undefined; + getActions(): ReadonlyArray; + getBindings(actionId: string): ReadonlyArray; + setBindings(actionId: string, bindings: ReadonlyArray): void; + reset(): void; +} + +interface ActionRecord extends InputActionState { + bindings: InputBinding[]; + deadzone: number; + sources: Map; +} + +interface BindingEntry { + action: ActionRecord; + binding: TBinding; + sourceKey: string; +} + +type KeyboardBindingEntry = BindingEntry; +type MouseBindingEntry = BindingEntry; +type GamepadButtonBindingEntry = BindingEntry; +type GamepadAxisBindingEntry = BindingEntry; + +export function createInputManager(options: InputManagerOptions): InputManager { + const { getTime } = options; + const axisThreshold = options.defaultAxisThreshold ?? DEFAULT_AXIS_THRESHOLD; + + const actionsById = new Map(); + const actions: ActionRecord[] = []; + + const keyboardBindings: KeyboardBindingEntry[] = []; + const mouseBindings: MouseBindingEntry[] = []; + const gamepadButtonBindings: GamepadButtonBindingEntry[] = []; + const gamepadAxisBindings: GamepadAxisBindingEntry[] = []; + + function resolveTime(explicit?: number): number { + if (explicit !== undefined) { + assertFiniteNumber(explicit, 'time'); + return explicit; + } + if (getTime) { + const value = getTime(); + assertFiniteNumber(value, 'getTime()'); + return value; + } + return Date.now() / 1000; + } + + function registerAction(definition: InputActionDefinition): void { + validateActionDefinition(definition); + const normalized: ActionRecord = { + id: definition.id, + bindings: [...definition.bindings], + type: definition.type ?? DEFAULT_ACTION_TYPE, + deadzone: definition.deadzone ?? axisThreshold, + active: false, + value: 0, + changedAt: 0, + sources: new Map(), + }; + actionsById.set(normalized.id, normalized); + actions.push(normalized); + registerBindings(normalized); + } + + function clearBindings(action: ActionRecord): void { + filterInPlace(keyboardBindings, (entry) => entry.action !== action); + filterInPlace(mouseBindings, (entry) => entry.action !== action); + filterInPlace(gamepadButtonBindings, (entry) => entry.action !== action); + filterInPlace(gamepadAxisBindings, (entry) => entry.action !== action); + action.sources.clear(); + action.active = false; + action.value = 0; + action.changedAt = resolveTime(); + } + + function registerBindings(action: ActionRecord): void { + action.bindings.forEach((binding, index) => { + validateBinding(binding, action.id); + const sourceKey = `${action.id}:${index}`; + switch (binding.device) { + case 'keyboard': + keyboardBindings.push({ action, binding, sourceKey }); + break; + case 'mouse': + mouseBindings.push({ action, binding, sourceKey }); + break; + case 'gamepad-button': + gamepadButtonBindings.push({ action, binding, sourceKey }); + break; + case 'gamepad-axis': + gamepadAxisBindings.push({ action, binding, sourceKey }); + break; + } + }); + } + + function applySourceValue(action: ActionRecord, sourceKey: string, rawValue: number, timestamp: number): boolean { + if (rawValue === 0) { + action.sources.delete(sourceKey); + } else { + action.sources.set(sourceKey, rawValue); + } + + const previousValue = action.value; + const previousActive = action.active; + + if (action.type === 'digital') { + action.value = action.sources.size > 0 ? 1 : 0; + action.active = action.value === 1; + } else { + let best = 0; + for (const candidate of action.sources.values()) { + if (Math.abs(candidate) > Math.abs(best)) { + best = candidate; + } + } + action.value = best; + action.active = action.sources.size > 0; + } + + if (action.value !== previousValue || action.active !== previousActive) { + action.changedAt = timestamp; + return true; + } + return false; + } + + function handleKeyboard(event: KeyInputEvent): boolean { + const timestamp = resolveTime(event.time); + let handled = false; + for (const entry of keyboardBindings) { + if (!matchesKeyboardBinding(entry.binding, event)) { + continue; + } + const value = event.type === 'down' ? 1 : 0; + if (applySourceValue(entry.action, entry.sourceKey, value, timestamp)) { + handled = true; + } + } + return handled; + } + + function handlePointer(event: PointerInputEvent): boolean { + const timestamp = resolveTime(event.time); + let handled = false; + for (const entry of mouseBindings) { + if (entry.binding.button !== event.button) { + continue; + } + const value = event.type === 'down' ? 1 : 0; + if (applySourceValue(entry.action, entry.sourceKey, value, timestamp)) { + handled = true; + } + } + return handled; + } + + function handleGamepadButtonEvent(event: GamepadButtonEvent): boolean { + const timestamp = resolveTime(event.time); + let handled = false; + const eventGamepadId = normalizeGamepadId(event.gamepadId); + const eventButton = normalizeButton(event.button); + for (const entry of gamepadButtonBindings) { + const binding = entry.binding; + if (eventGamepadId !== normalizeGamepadId(binding.gamepadId)) { + continue; + } + if (eventButton !== normalizeButton(binding.button)) { + continue; + } + const value = event.type === 'down' ? clampDigitalValue(event.value ?? 1) : 0; + if (applySourceValue(entry.action, entry.sourceKey, value, timestamp)) { + handled = true; + } + } + return handled; + } + + function handleGamepadAxisEvent(event: GamepadAxisEvent): boolean { + assertFiniteNumber(event.value, 'value'); + const timestamp = resolveTime(event.time); + let handled = false; + const eventGamepadId = normalizeGamepadId(event.gamepadId); + for (const entry of gamepadAxisBindings) { + const binding = entry.binding; + if (binding.axis !== event.axis) { + continue; + } + if (eventGamepadId !== normalizeGamepadId(binding.gamepadId)) { + continue; + } + const threshold = binding.threshold ?? entry.action.deadzone ?? axisThreshold; + const value = computeAxisValue(binding, event.value, threshold); + if (applySourceValue(entry.action, entry.sourceKey, value, timestamp)) { + handled = true; + } + } + return handled; + } + + function getActionState(actionId: string): ActionRecord { + const action = actionsById.get(actionId); + if (!action) { + throw new Error(`Unknown action: ${actionId}`); + } + return action; + } + + function getBindingsSnapshot(action: ActionRecord): InputBinding[] { + return action.bindings.map((binding) => ({ ...binding })); + } + + function setBindingsForAction(actionId: string, bindings: ReadonlyArray): void { + const action = getActionState(actionId); + clearBindings(action); + action.bindings = bindings.map((binding) => ({ ...binding })); + registerBindings(action); + } + + function resetManager(): void { + const timestamp = resolveTime(); + for (const action of actions) { + action.sources.clear(); + action.value = 0; + if (action.active) { + action.active = false; + action.changedAt = timestamp; + } + } + } + + const seenIds = new Set(); + for (const definition of options.actions) { + if (seenIds.has(definition.id)) { + throw new Error(`Duplicate action id: ${definition.id}`); + } + seenIds.add(definition.id); + registerAction(definition); + } + + return { + handleKeyEvent: (event) => handleKeyboard(event), + handlePointerEvent: (event) => handlePointer(event), + handleGamepadButton: (event) => handleGamepadButtonEvent(event), + handleGamepadAxis: (event) => handleGamepadAxisEvent(event), + isActive: (actionId) => getActionState(actionId).active, + getValue: (actionId) => getActionState(actionId).value, + getState: (actionId) => { + const action = getActionState(actionId); + return { id: action.id, active: action.active, value: action.value, changedAt: action.changedAt, type: action.type }; + }, + getActions: () => actions.map((action) => ({ id: action.id, active: action.active, value: action.value, changedAt: action.changedAt, type: action.type })), + getBindings: (actionId) => getBindingsSnapshot(getActionState(actionId)), + setBindings: (actionId, bindings) => setBindingsForAction(actionId, bindings), + reset: () => resetManager(), + }; +} + +function filterInPlace(array: T[], predicate: (item: T) => boolean): void { + let writeIndex = 0; + for (let readIndex = 0; readIndex < array.length; readIndex += 1) { + const item = array[readIndex]; + if (predicate(item)) { + array[writeIndex] = item; + writeIndex += 1; + } + } + array.length = writeIndex; +} + +function matchesKeyboardBinding(binding: KeyboardBinding, event: KeyInputEvent): boolean { + if (binding.key.toLowerCase() !== event.key.toLowerCase()) { + return false; + } + if (!matchesModifier(binding.ctrlKey, event.ctrlKey)) { + return false; + } + if (!matchesModifier(binding.shiftKey, event.shiftKey)) { + return false; + } + if (!matchesModifier(binding.altKey, event.altKey)) { + return false; + } + if (!matchesModifier(binding.metaKey, event.metaKey)) { + return false; + } + return true; +} + +function matchesModifier(expected: boolean | undefined, actual: boolean | undefined): boolean { + if (expected === undefined) { + return true; + } + return Boolean(actual) === expected; +} + +function normalizeGamepadId(id: string | undefined): string { + return id ?? DEFAULT_GAMEPAD_ID; +} + +function normalizeButton(value: string | number): string { + return typeof value === 'number' ? `#${value}` : value; +} + +function clampDigitalValue(value: number): number { + if (value <= 0) { + return 0; + } + return 1; +} + +function computeAxisValue(binding: GamepadAxisBinding, rawValue: number, threshold: number): number { + assertFiniteNumber(rawValue, 'axis value'); + const direction = binding.direction ?? 'both'; + const magnitude = Math.abs(rawValue); + if (magnitude < threshold) { + return 0; + } + if (direction === 'positive') { + return rawValue > 0 ? rawValue : 0; + } + if (direction === 'negative') { + return rawValue < 0 ? rawValue : 0; + } + return rawValue; +} + +function validateActionDefinition(definition: InputActionDefinition): void { + if (!definition || typeof definition.id !== 'string' || definition.id.length === 0) { + throw new Error('Action definitions must include a non-empty id.'); + } + if (!Array.isArray(definition.bindings)) { + throw new Error(`Action "${definition.id}" must include bindings.`); + } + if (definition.type && definition.type !== 'digital' && definition.type !== 'analog') { + throw new Error(`Action "${definition.id}" has invalid type.`); + } + if (definition.deadzone !== undefined) { + assertFiniteNumber(definition.deadzone, 'deadzone'); + if (definition.deadzone < 0 || definition.deadzone >= 1) { + throw new Error('deadzone must be between 0 and 1.'); + } + } +} + +function validateBinding(binding: InputBinding, actionId: string): void { + switch (binding.device) { + case 'keyboard': + if (typeof binding.key !== 'string' || binding.key.length === 0) { + throw new Error(`Keyboard binding for action "${actionId}" requires a key.`); + } + break; + case 'mouse': + if (!Number.isInteger(binding.button)) { + throw new Error(`Mouse binding for action "${actionId}" requires a button index.`); + } + break; + case 'gamepad-button': + if ((typeof binding.button !== 'string' || binding.button.length === 0) && typeof binding.button !== 'number') { + throw new Error(`Gamepad button binding for action "${actionId}" requires a button identifier.`); + } + break; + case 'gamepad-axis': + if (!Number.isInteger(binding.axis)) { + throw new Error(`Gamepad axis binding for action "${actionId}" requires an axis index.`); + } + if (binding.threshold !== undefined) { + assertFiniteNumber(binding.threshold, 'threshold'); + if (binding.threshold < 0 || binding.threshold >= 1) { + throw new Error('threshold must be between 0 and 1.'); + } + } + break; + default: + throw new Error(`Unknown binding device for action "${actionId}".`); + } +} + +function assertFiniteNumber(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} diff --git a/src/gameplay/questMachine.ts b/src/gameplay/questMachine.ts index 90a55bd..a0aa7a6 100644 --- a/src/gameplay/questMachine.ts +++ b/src/gameplay/questMachine.ts @@ -1,11 +1,14 @@ -export interface QuestStateNode { +export interface QuestStateNode> { id: string; terminal?: boolean; onEnter?: (context: TContext, payload?: unknown) => void; onExit?: (context: TContext, payload?: unknown) => void; } -export interface QuestTransition { +export interface QuestTransition< + TContext extends Record, + TEvent = unknown +> { from: string; to: string; event: string; @@ -23,7 +26,7 @@ export interface QuestMachineOptions< context: TContext; } -export interface QuestMachineSnapshot { +export interface QuestMachineSnapshot> { state: string; context: TContext; } diff --git a/src/gameplay/saveManager.ts b/src/gameplay/saveManager.ts new file mode 100644 index 0000000..7ec86da --- /dev/null +++ b/src/gameplay/saveManager.ts @@ -0,0 +1,322 @@ +const DEFAULT_PREFIX = 'slot'; + +interface StoredPayload { + checksum: string; + updatedAt: number; + version?: number; + size: number; + data: string; +} + +export interface SaveSlotMetadata { + slotId: string; + checksum: string; + updatedAt: number; + size: number; + version?: number; +} + +export interface SaveResult { + metadata: SaveSlotMetadata; + overwritten?: SaveSlotMetadata; + evicted?: ReadonlyArray; +} + +export type LoadError = 'not-found' | 'corrupted' | 'parse-error'; + +export interface LoadResult { + ok: boolean; + slotId: string; + data?: T; + metadata?: SaveSlotMetadata; + error?: LoadError; +} + +export interface SaveStorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + keys(): Iterable; +} + +export interface SaveManagerOptions { + prefix?: string; + storage?: SaveStorageAdapter; + serializer?: (data: T) => string; + deserializer?: (raw: string) => T; + checksum?: (raw: string) => string; + getTime?: () => number; + version?: number; + maxSlots?: number; +} + +export interface SaveManager { + save(slotId: string, data: T, time?: number): SaveResult; + load(slotId: string): LoadResult; + delete(slotId: string): SaveSlotMetadata | null; + list(): ReadonlyArray; + get(slotId: string): SaveSlotMetadata | null; + verify(slotId: string): boolean; + clear(): ReadonlyArray; + getStorage(): SaveStorageAdapter; +} + +export function createMemorySaveStorage(): SaveStorageAdapter { + const memory = new Map(); + return { + getItem: (key) => memory.get(key) ?? null, + setItem: (key, value) => { + memory.set(key, value); + }, + removeItem: (key) => { + memory.delete(key); + }, + keys: () => memory.keys(), + }; +} + +export function createSaveManager(options: SaveManagerOptions): SaveManager { + const storage = options.storage ?? createMemorySaveStorage(); + const serializer = options.serializer ?? ((data: T) => JSON.stringify(data)); + const deserializer = options.deserializer ?? ((raw: string) => JSON.parse(raw) as T); + const checksum = options.checksum ?? defaultChecksum; + const prefix = options.prefix ?? DEFAULT_PREFIX; + const version = options.version; + const getTime = options.getTime; + const maxSlots = options.maxSlots; + + if (maxSlots !== undefined) { + assertPositiveInteger(maxSlots, 'maxSlots'); + } + + const prefixKey = `${prefix}::`; + + function resolveTime(explicit?: number): number { + if (explicit !== undefined) { + assertFiniteNumber(explicit, 'time'); + return explicit; + } + if (getTime) { + const value = getTime(); + assertFiniteNumber(value, 'getTime()'); + return value; + } + return Date.now() / 1000; + } + + function composeKey(slotId: string): string { + return `${prefixKey}${slotId}`; + } + + function save(slotId: string, data: T, time?: number): SaveResult { + const normalizedSlotId = normalizeSlotId(slotId); + const timestamp = resolveTime(time); + const raw = serializer(data); + if (typeof raw !== 'string') { + throw new Error('serializer must return a string.'); + } + const payload: StoredPayload = { + checksum: checksum(raw), + updatedAt: timestamp, + version, + size: raw.length, + data: raw, + }; + + const key = composeKey(normalizedSlotId); + const previous = readSlot(normalizedSlotId); + storage.setItem(key, JSON.stringify(payload)); + + const metadata = toMetadata(normalizedSlotId, payload); + let evicted: SaveSlotMetadata[] | undefined; + + if (maxSlots !== undefined) { + const all = getAllSlots(); + if (all.length > maxSlots) { + const sorted = all + .sort((a, b) => a.metadata.updatedAt - b.metadata.updatedAt) + .filter((entry) => entry.metadata.slotId !== normalizedSlotId || entry.key !== key); + while (sorted.length > 0 && all.length - (evicted?.length ?? 0) > maxSlots) { + const oldest = sorted.shift(); + if (!oldest) { + break; + } + storage.removeItem(oldest.key); + if (!evicted) { + evicted = []; + } + evicted.push(oldest.metadata); + } + } + } + + return { + metadata, + overwritten: previous?.metadata, + evicted, + }; + } + + function load(slotId: string): LoadResult { + const normalizedSlotId = normalizeSlotId(slotId); + const record = readSlot(normalizedSlotId); + if (!record) { + return { ok: false, slotId: normalizedSlotId, error: 'not-found' }; + } + const { payload, metadata } = record; + const expected = checksum(payload.data); + if (payload.checksum !== expected) { + return { ok: false, slotId: normalizedSlotId, error: 'corrupted', metadata }; + } + try { + return { + ok: true, + slotId: normalizedSlotId, + data: deserializer(payload.data), + metadata, + }; + } catch (_error) { + return { ok: false, slotId: normalizedSlotId, error: 'parse-error', metadata }; + } + } + + function deleteSlot(slotId: string): SaveSlotMetadata | null { + const normalizedSlotId = normalizeSlotId(slotId); + const record = readSlot(normalizedSlotId); + if (!record) { + return null; + } + storage.removeItem(record.key); + return record.metadata; + } + + function list(): ReadonlyArray { + return getAllSlots() + .map((entry) => entry.metadata) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + + function get(slotId: string): SaveSlotMetadata | null { + const normalizedSlotId = normalizeSlotId(slotId); + const record = readSlot(normalizedSlotId); + return record?.metadata ?? null; + } + + function verify(slotId: string): boolean { + const normalizedSlotId = normalizeSlotId(slotId); + const record = readSlot(normalizedSlotId); + if (!record) { + return false; + } + const expected = checksum(record.payload.data); + return expected === record.payload.checksum; + } + + function clear(): ReadonlyArray { + const removed: SaveSlotMetadata[] = []; + for (const entry of getAllSlots()) { + storage.removeItem(entry.key); + removed.push(entry.metadata); + } + return removed; + } + + function getAllSlots(): Array<{ key: string; payload: StoredPayload; metadata: SaveSlotMetadata }> { + const entries: Array<{ key: string; payload: StoredPayload; metadata: SaveSlotMetadata }> = []; + for (const key of storage.keys()) { + if (!key.startsWith(prefixKey)) { + continue; + } + const slotId = key.slice(prefixKey.length); + const record = readKey(key, slotId); + if (record) { + entries.push(record); + } + } + return entries; + } + + function readKey(key: string, slotId: string): { key: string; payload: StoredPayload; metadata: SaveSlotMetadata } | null { + const raw = storage.getItem(key); + if (raw === null) { + return null; + } + try { + const payload = JSON.parse(raw) as StoredPayload; + if ( + !payload || + typeof payload.data !== 'string' || + typeof payload.updatedAt !== 'number' || + typeof payload.checksum !== 'string' || + typeof payload.size !== 'number' + ) { + return null; + } + const metadata = toMetadata(slotId, payload); + return { key, payload, metadata }; + } catch (_error) { + return null; + } + } + + function readSlot(slotId: string): { key: string; payload: StoredPayload; metadata: SaveSlotMetadata } | null { + return readKey(composeKey(slotId), slotId); + } + + function getStorage(): SaveStorageAdapter { + return storage; + } + + return { + save, + load, + delete: deleteSlot, + list, + get, + verify, + clear, + getStorage, + }; +} + +function toMetadata(slotId: string, payload: StoredPayload): SaveSlotMetadata { + return { + slotId, + checksum: payload.checksum, + updatedAt: payload.updatedAt, + size: payload.size, + version: payload.version, + }; +} + +function normalizeSlotId(slotId: string): string { + if (typeof slotId !== 'string') { + throw new Error('slotId must be a string.'); + } + const trimmed = slotId.trim(); + if (trimmed.length === 0) { + throw new Error('slotId must not be empty.'); + } + return trimmed; +} + +function defaultChecksum(input: string): string { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} + +function assertFiniteNumber(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } +} diff --git a/src/gameplay/screenTransitions.ts b/src/gameplay/screenTransitions.ts new file mode 100644 index 0000000..432b35f --- /dev/null +++ b/src/gameplay/screenTransitions.ts @@ -0,0 +1,204 @@ +type TransitionPhase = 'idle' | 'in' | 'hold' | 'out' | 'completed'; + +export interface ScreenTransitionOptions { + durationIn: number; + durationOut: number; + hold?: number; + easingIn?: (t: number) => number; + easingOut?: (t: number) => number; +} + +export interface ScreenTransitionState { + phase: TransitionPhase; + progress: number; + value: number; + elapsed: number; + totalDuration: number; +} + +export interface ScreenTransitionController { + start(): void; + update(delta: number): ScreenTransitionState; + getState(): ScreenTransitionState; + reset(): void; + isActive(): boolean; + isCompleted(): boolean; +} + +export function createScreenTransition(options: ScreenTransitionOptions): ScreenTransitionController { + validateOptions(options); + const hold = options.hold ?? 0; + const totalDuration = options.durationIn + hold + options.durationOut; + let phase: TransitionPhase = 'idle'; + let elapsedPhase = 0; + let elapsedTotal = 0; + let state: ScreenTransitionState = { + phase, + progress: 0, + value: 0, + elapsed: 0, + totalDuration, + }; + + function start(): void { + phase = 'in'; + elapsedPhase = 0; + elapsedTotal = 0; + updateState(0); + } + + function reset(): void { + phase = 'idle'; + elapsedPhase = 0; + elapsedTotal = 0; + updateState(0); + } + + function update(delta: number): ScreenTransitionState { + assertNonNegative(delta, 'delta'); + if (phase === 'idle' || phase === 'completed') { + return state; + } + + let remaining = delta; + while (remaining > 0 && phase !== 'completed') { + const duration = getPhaseDuration(phase, options, hold); + const timeLeft = duration - elapsedPhase; + const step = Math.min(remaining, Math.max(timeLeft, 0)); + elapsedPhase += step; + elapsedTotal += step; + remaining -= step; + + if (phase === 'in') { + const progress = duration === 0 ? 1 : clamp(elapsedPhase / duration); + updateState(progress, options.easingIn ?? easeLinear); + if (elapsedPhase >= duration) { + phase = hold > 0 ? 'hold' : 'out'; + elapsedPhase = 0; + if (phase === 'hold') { + updateState(1); + } + } + } else if (phase === 'hold') { + updateState(1); + if (elapsedPhase >= duration) { + phase = 'out'; + elapsedPhase = 0; + updateState(1, options.easingOut ?? easeReverseLinear); + } + } else if (phase === 'out') { + const progress = duration === 0 ? 1 : clamp(elapsedPhase / duration); + updateState(1 - progress, options.easingOut ?? easeReverseLinear); + if (elapsedPhase >= duration) { + phase = 'completed'; + updateState(0); + } + } + + if (timeLeft <= 0 && step === 0) { + // Avoid infinite loops if duration is zero. + break; + } + } + + return state; + } + + function getPhaseDuration(current: TransitionPhase, cfg: ScreenTransitionOptions, holdDuration: number): number { + switch (current) { + case 'in': + return cfg.durationIn; + case 'hold': + return holdDuration; + case 'out': + return cfg.durationOut; + default: + return 0; + } + } + + function updateState(progress: number, easing?: (t: number) => number): void { + const value = easing ? easing(clamp(progress)) : clamp(progress); + state = { + phase, + progress: clamp(progress), + value, + elapsed: elapsedTotal, + totalDuration, + }; + } + + return { + start, + update, + getState: () => state, + reset, + isActive: () => phase !== 'idle' && phase !== 'completed', + isCompleted: () => phase === 'completed', + }; +} + +export interface FadeResult { + opacity: number; +} + +export function computeFade(state: ScreenTransitionState): FadeResult { + return { opacity: clamp(state.value) }; +} + +export interface WipeResult { + offset: number; + direction: 'left' | 'right'; +} + +export function computeHorizontalWipe(state: ScreenTransitionState, direction: 'left' | 'right' = 'left'): WipeResult { + const progress = direction === 'left' ? 1 - state.value : state.value; + return { offset: clamp(progress), direction }; +} + +export interface LetterboxResult { + barSize: number; +} + +export function computeLetterbox(state: ScreenTransitionState, maxBar: number): LetterboxResult { + assertNonNegative(maxBar, 'maxBar'); + return { barSize: clamp(state.value) * maxBar }; +} + +function validateOptions(options: ScreenTransitionOptions): void { + assertPositive(options.durationIn, 'durationIn'); + assertPositive(options.durationOut, 'durationOut'); + if (options.hold !== undefined) { + assertNonNegative(options.hold, 'hold'); + } +} + +function easeLinear(t: number): number { + return clamp(t); +} + +function easeReverseLinear(t: number): number { + return clamp(t); +} + +function clamp(value: number): number { + if (value <= 0) { + return 0; + } + if (value >= 1) { + return 1; + } + return value; +} + +function assertPositive(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value) || value <= 0) { + throw new Error(`${label} must be a positive number.`); + } +} + +function assertNonNegative(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a non-negative number.`); + } +} diff --git a/src/gameplay/soundManager.ts b/src/gameplay/soundManager.ts new file mode 100644 index 0000000..d92fdd6 --- /dev/null +++ b/src/gameplay/soundManager.ts @@ -0,0 +1,249 @@ +const DEFAULT_CHANNEL = 'master'; + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } +} + +function assertNonNegativeNumber(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } + if (value < 0) { + throw new Error(`${label} must be non-negative.`); + } +} + +function assertPositiveNumber(value: number, label: string): void { + assertNonNegativeNumber(value, label); + if (value === 0) { + throw new Error(`${label} must be greater than zero.`); + } +} + +export interface SoundManagerOptions { + maxChannels: number; + channelLimits?: Record; + getTime?: () => number; +} + +export interface PlaySoundOptions { + soundId: string; + duration: number; + priority?: number; + channel?: string; + metadata?: TMetadata; + time?: number; +} + +export interface SoundHandle { + handleId: number; + soundId: string; + channel: string; + priority: number; + startedAt: number; + endsAt: number; + metadata?: TMetadata; +} + +export interface PlaySoundResult { + accepted: boolean; + handle?: SoundHandle; + evicted?: SoundHandle; + reason?: 'channel-limit'; +} + +export interface SoundManager { + play(options: PlaySoundOptions): PlaySoundResult; + stop(handleId: number): SoundHandle | null; + update(time?: number): SoundHandle[]; + getActive(): ReadonlyArray>; + setMaxChannels(count: number): void; + getMaxChannels(): number; + reset(): SoundHandle[]; +} + +export function createSoundManager( + options: SoundManagerOptions +): SoundManager { + assertPositiveInteger(options.maxChannels, 'maxChannels'); + + const getTime = options.getTime; + const channelLimits = new Map(); + if (options.channelLimits) { + for (const [channel, limit] of Object.entries(options.channelLimits)) { + assertPositiveInteger(limit, `channelLimits.${channel}`); + channelLimits.set(channel, limit); + } + } + + let maxChannels = options.maxChannels; + let handleCounter = 0; + const active: SoundHandle[] = []; + + function resolveTime(explicit?: number): number { + if (explicit !== undefined) { + assertNonNegativeNumber(explicit, 'time'); + return explicit; + } + if (getTime) { + const value = getTime(); + assertNonNegativeNumber(value, 'getTime()'); + return value; + } + return Date.now() / 1000; + } + + function getChannelLimit(channel: string): number { + return channelLimits.get(channel) ?? maxChannels; + } + + function removeHandle(handle: SoundHandle): void { + const index = active.findIndex((entry) => entry.handleId === handle.handleId); + if (index >= 0) { + active.splice(index, 1); + } + } + + function capacityReached(channel: string): boolean { + if (active.length >= maxChannels) { + return true; + } + const perChannelLimit = getChannelLimit(channel); + if (perChannelLimit <= 0) { + return true; + } + let channelCount = 0; + for (const handle of active) { + if (handle.channel === channel) { + channelCount += 1; + if (channelCount >= perChannelLimit) { + return true; + } + } + } + return false; + } + + function update(time?: number): SoundHandle[] { + const expiresAt = resolveTime(time); + const removed: SoundHandle[] = []; + for (let index = active.length - 1; index >= 0; index -= 1) { + if (active[index].endsAt <= expiresAt) { + removed.push(active[index]); + active.splice(index, 1); + } + } + return removed; + } + + function play(options: PlaySoundOptions): PlaySoundResult { + const channel = options.channel ?? DEFAULT_CHANNEL; + const priority = options.priority ?? 0; + assertNonNegativeNumber(priority, 'priority'); + assertPositiveNumber(options.duration, 'duration'); + + const currentTime = resolveTime(options.time); + update(currentTime); + + let evicted: SoundHandle | undefined; + + if (capacityReached(channel)) { + const victims: SoundHandle[] = []; + if (active.length >= maxChannels) { + victims.push(...active); + } + const channelLimit = getChannelLimit(channel); + if (channelLimit <= 0) { + return { accepted: false, reason: 'channel-limit' }; + } + if (victims.length === 0) { + for (const handle of active) { + if (handle.channel === channel) { + victims.push(handle); + } + } + } + + let candidate: SoundHandle | null = null; + for (const handle of victims) { + if (!candidate || handle.priority < candidate.priority) { + candidate = handle; + continue; + } + if (candidate && handle.priority === candidate.priority && handle.startedAt < candidate.startedAt) { + candidate = handle; + } + } + + if (!candidate || candidate.priority >= priority) { + return { accepted: false, reason: 'channel-limit' }; + } + + removeHandle(candidate); + evicted = candidate; + } + + const handle: SoundHandle = { + handleId: ++handleCounter, + soundId: options.soundId, + channel, + priority, + startedAt: currentTime, + endsAt: currentTime + options.duration, + metadata: options.metadata, + }; + + active.push(handle); + + return { accepted: true, handle, evicted }; + } + + function stop(handleId: number): SoundHandle | null { + const index = active.findIndex((handle) => handle.handleId === handleId); + if (index === -1) { + return null; + } + const [removed] = active.splice(index, 1); + return removed; + } + + function getActive(): ReadonlyArray> { + return active.map((handle) => ({ ...handle })); + } + + function setMaxChannels(count: number): void { + assertPositiveInteger(count, 'maxChannels'); + maxChannels = count; + if (active.length > maxChannels) { + active + .sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return a.startedAt - b.startedAt; + }) + .splice(0, active.length - maxChannels); + } + } + + function getMaxChannels(): number { + return maxChannels; + } + + function reset(): SoundHandle[] { + const removed = active.splice(0, active.length); + return removed; + } + + return { + play, + stop, + update, + getActive, + setMaxChannels, + getMaxChannels, + reset, + }; +} diff --git a/src/gameplay/waveSpawner.ts b/src/gameplay/waveSpawner.ts new file mode 100644 index 0000000..99eb83a --- /dev/null +++ b/src/gameplay/waveSpawner.ts @@ -0,0 +1,203 @@ +export interface SpawnPayload { + waveIndex: number; + entityIndex: number; + template: T; +} + +export interface WaveDefinition { + /** Delay in seconds before this wave starts. */ + delay: number; + /** Number of entities to spawn in this wave. */ + count: number; + /** Template or metadata for spawned entities. */ + template: T; + /** Spacing in seconds between each spawn inside the wave. */ + interval?: number; +} + +export interface WaveSpawnerOptions { + waves: ReadonlyArray>; + loop?: boolean; +} + +export interface WaveSpawnerSnapshot { + waveIndex: number; + timeUntilNextSpawn: number; + spawnedInWave: number; + looped: number; +} + +export interface WaveSpawner { + update(delta: number): SpawnPayload[]; + isFinished(): boolean; + reset(snapshot?: WaveSpawnerSnapshot): void; + toJSON(): WaveSpawnerSnapshot; +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function assertNonNegative(value: number, label: string): void { + assertFinite(value, label); + if (value < 0) { + throw new Error(`${label} must be non-negative.`); + } +} + +function normalizeWaves(waves: ReadonlyArray>): WaveDefinition[] { + if (!Array.isArray(waves) || waves.length === 0) { + throw new Error('waves must contain at least one definition.'); + } + return waves.map((wave: WaveDefinition, index) => { + const { delay, count, template } = wave; + const interval = wave.interval ?? 0; + assertNonNegative(delay, `waves[${index}].delay`); + if (!Number.isInteger(count) || count <= 0) { + throw new Error(`waves[${index}].count must be a positive integer.`); + } + if (wave.interval !== undefined) { + assertNonNegative(interval, `waves[${index}].interval`); + } + return { + delay, + count, + template, + interval, + }; + }); +} + +/** + * Creates a wave spawner that emits spawn payloads based on configured waves. + * Useful for: enemy spawning, encounter scripting, and timed events. + */ +export function createWaveSpawner(options: WaveSpawnerOptions): WaveSpawner { + const waves = normalizeWaves(options.waves); + const loop = options.loop ?? false; + const EPSILON = 1e-8; + + let waveIndex = 0; + let timeUntilNextSpawn = waves[0]?.delay ?? 0; + let spawnedInWave = 0; + let loopCount = 0; + + function setState(index: number, time: number, spawned: number, loops: number): void { + waveIndex = index; + timeUntilNextSpawn = time; + spawnedInWave = spawned; + loopCount = loops; + } + + function advanceWave(leftover: number): boolean { + spawnedInWave = 0; + waveIndex += 1; + if (waveIndex >= waves.length) { + if (!loop) { + waveIndex = waves.length; + timeUntilNextSpawn = 0; + return false; + } + loopCount += 1; + waveIndex = 0; + } + const nextDelay = waves[waveIndex]?.delay ?? 0; + timeUntilNextSpawn = nextDelay + leftover; + return leftover < -EPSILON; + } + + function update(delta: number): SpawnPayload[] { + assertNonNegative(delta, 'delta'); + const spawns: SpawnPayload[] = []; + + if (waveIndex >= waves.length) { + return spawns; + } + + timeUntilNextSpawn -= delta; + + while (waveIndex < waves.length && timeUntilNextSpawn <= EPSILON) { + const currentWave = waves[waveIndex]; + const leftover = timeUntilNextSpawn; + spawnEntity(spawns, currentWave); + + if (spawnedInWave >= currentWave.count) { + const carried = advanceWave(leftover); + if (waveIndex >= waves.length || !carried) { + timeUntilNextSpawn = Math.max(timeUntilNextSpawn, 0); + break; + } + } else { + const nextInterval = currentWave.interval ?? 0; + timeUntilNextSpawn = nextInterval + leftover; + } + } + + if (timeUntilNextSpawn < 0 && waveIndex < waves.length) { + // Avoid accumulating large negative drift when consuming multiple spawns in a single frame. + timeUntilNextSpawn = Math.max(timeUntilNextSpawn, -EPSILON); + } + return spawns; + } + + function spawnEntity(spawns: SpawnPayload[], wave: WaveDefinition): void { + spawnedInWave += 1; + spawns.push({ + waveIndex, + entityIndex: spawnedInWave - 1, + template: wave.template, + }); + } + + function isFinished(): boolean { + return !loop && waveIndex >= waves.length; + } + + function reset(snapshot?: WaveSpawnerSnapshot): void { + if (snapshot) { + validateSnapshot(snapshot); + setState(snapshot.waveIndex, snapshot.timeUntilNextSpawn, snapshot.spawnedInWave, snapshot.looped); + } else { + setState(0, waves[0]?.delay ?? 0, 0, 0); + } + } + + function toJSON(): WaveSpawnerSnapshot { + return { + waveIndex, + timeUntilNextSpawn, + spawnedInWave, + looped: loopCount, + }; + } + + function validateSnapshot(snapshot: WaveSpawnerSnapshot): void { + if (!Number.isInteger(snapshot.waveIndex) || snapshot.waveIndex < 0 || snapshot.waveIndex > waves.length) { + throw new Error('snapshot.waveIndex is out of range.'); + } + assertNonNegative(snapshot.timeUntilNextSpawn, 'snapshot.timeUntilNextSpawn'); + if (!Number.isInteger(snapshot.spawnedInWave) || snapshot.spawnedInWave < 0) { + throw new Error('snapshot.spawnedInWave must be a non-negative integer.'); + } + if (!Number.isInteger(snapshot.looped) || snapshot.looped < 0) { + throw new Error('snapshot.looped must be a non-negative integer.'); + } + if (snapshot.waveIndex < waves.length) { + const wave = waves[snapshot.waveIndex]; + if (snapshot.spawnedInWave > wave.count) { + throw new Error('snapshot.spawnedInWave exceeds wave count.'); + } + } else if (snapshot.waveIndex === waves.length && snapshot.spawnedInWave !== 0) { + throw new Error('Completed snapshots must not track spawned entities.'); + } + } + + return { + update, + isFinished, + reset, + toJSON, + }; +} diff --git a/src/index.ts b/src/index.ts index 799bf60..e8de8ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,12 @@ export const examples = { Trie: 'examples/search.ts', binarySearch: 'examples/search.ts', levenshteinDistance: 'examples/search.ts', + kmpSearch: 'examples/search.ts', + rabinKarp: 'examples/search.ts', + boyerMooreSearch: 'examples/search.ts', + buildSuffixArray: 'examples/search.ts', + longestCommonSubsequence: 'examples/search.ts', + diffStrings: 'examples/search.ts', }, data: { diff: 'examples/jsonDiff.ts', @@ -76,6 +82,9 @@ export const examples = { groupBy: 'examples/jsonDiff.ts', diffJson: 'examples/jsonDiff.ts', applyJsonDiff: 'examples/jsonDiff.ts', + flatten: 'examples/jsonDiff.ts', + unflatten: 'examples/jsonDiff.ts', + paginate: 'examples/pagination.ts', }, performance: { debounce: 'examples/requestDedup.ts', @@ -105,6 +114,11 @@ export const examples = { createCooldownController: 'examples/combat.ts', createQuestMachine: 'examples/quest.ts', computeLightingGrid: 'examples/lighting.ts', + createWaveSpawner: 'examples/waveSpawner.ts', + createSoundManager: 'examples/soundManager.ts', + createInputManager: 'examples/inputManager.ts', + createSaveManager: 'examples/saveManager.ts', + createScreenTransition: 'examples/screenTransitions.ts', }, ai: { seek: 'examples/steering.ts', @@ -115,6 +129,9 @@ export const examples = { updateBoids: 'examples/boids.ts', BehaviorTree: 'examples/behaviorTree.ts', rvoStep: 'examples/rvo.ts', + createFSM: 'examples/fsm.ts', + createGeneticAlgorithm: 'examples/genetic.ts', + computeInfluenceMap: 'examples/influenceMap.ts', }, graph: { graphBFS: 'examples/graph.ts', @@ -701,6 +718,138 @@ export type { FalloffMode, } from './gameplay/lighting.js'; +/** + * Wave spawner helper for timed encounters. + * + * Example file: examples/waveSpawner.ts + */ +export { createWaveSpawner } from './gameplay/waveSpawner.js'; + +export type { + WaveSpawner, + WaveSpawnerOptions, + WaveDefinition, + SpawnPayload, + WaveSpawnerSnapshot, +} from './gameplay/waveSpawner.js'; + +/** + * Sound manager helper for channel limiting and priority-based playback. + * + * Example file: examples/soundManager.ts + */ +export { createSoundManager } from './gameplay/soundManager.js'; + +export type { + SoundManager, + SoundManagerOptions, + PlaySoundOptions, + SoundHandle, + PlaySoundResult, +} from './gameplay/soundManager.js'; + +/** + * Input manager abstraction for keyboard, mouse, and gamepad remapping. + * + * Example file: examples/inputManager.ts + */ +export { createInputManager } from './gameplay/inputManager.js'; + +export type { + InputManager, + InputManagerOptions, + InputActionDefinition, + InputActionState, + InputActionType, + InputBinding, + KeyboardBinding, + MouseBinding, + GamepadButtonBinding, + GamepadAxisBinding, + KeyInputEvent, + PointerInputEvent, + GamepadButtonEvent, + GamepadAxisEvent, +} from './gameplay/inputManager.js'; + +/** + * Save/load helper for slot-based persistence with checksums. + * + * Example file: examples/saveManager.ts + */ +export { createSaveManager, createMemorySaveStorage } from './gameplay/saveManager.js'; + +export type { + SaveManager, + SaveManagerOptions, + SaveSlotMetadata, + SaveResult, + LoadResult, + LoadError, + SaveStorageAdapter, +} from './gameplay/saveManager.js'; + +/** + * Screen transition helpers (fade, wipes, letterboxing). + * + * Example file: examples/screenTransitions.ts + */ +export { + createScreenTransition, + computeFade, + computeHorizontalWipe, + computeLetterbox, +} from './gameplay/screenTransitions.js'; + +export type { + ScreenTransitionOptions, + ScreenTransitionState, + ScreenTransitionController, + FadeResult, + WipeResult, + LetterboxResult, +} from './gameplay/screenTransitions.js'; + +/** + * Finite state machine toolkit for stateful AI. + * + * Example file: examples/fsm.ts + */ +export { createFSM } from './ai/fsm.js'; + +export type { + StateDefinition, + TransitionDefinition, + FSMOptions, + FSMController, +} from './ai/fsm.js'; + +/** + * Genetic algorithm helper for evolving solutions. + * + * Example file: examples/genetic.ts + */ +export { createGeneticAlgorithm } from './ai/genetic.js'; + +export type { + GeneticAlgorithmOptions, + GeneticAlgorithmController, + ParentSelector, +} from './ai/genetic.js'; + +/** + * Influence map computation for tactical AI. + * + * Example file: examples/influenceMap.ts + */ +export { computeInfluenceMap } from './ai/influenceMap.js'; + +export type { + InfluenceSource, + InfluenceMapOptions, + InfluenceMapResult, +} from './ai/influenceMap.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES @@ -721,6 +870,41 @@ export { Trie } from './search/trie.js'; */ export { binarySearch } from './search/binarySearch.js'; +/** + * Knuth–Morris–Pratt substring search helper. + */ +export { kmpSearch } from './search/kmp.js'; + +export type { KMPSearchOptions } from './search/kmp.js'; + +/** + * Rabin–Karp multiple pattern matcher. + */ +export { rabinKarp } from './search/rabinKarp.js'; + +export type { RabinKarpOptions } from './search/rabinKarp.js'; + +/** + * Boyer–Moore substring matcher. + */ +export { boyerMooreSearch } from './search/boyerMoore.js'; + +export type { BoyerMooreOptions } from './search/boyerMoore.js'; + +/** + * Suffix array construction utilities. + */ +export { buildSuffixArray } from './search/suffixArray.js'; + +export type { SuffixArrayOptions, SuffixArrayResult } from './search/suffixArray.js'; + +/** + * Longest common subsequence and diff helpers. + */ +export { longestCommonSubsequence, diffStrings } from './search/lcs.js'; + +export type { LCSOptions, LCSResult, DiffOp } from './search/lcs.js'; + /** * Levenshtein distance computation for strings. */ @@ -748,7 +932,21 @@ export { groupBy } from './data/groupBy.js'; /** * JSON diff and patch helpers for nested structures. */ -export { diffJson, applyJsonDiff } from './data/jsonDiff.js'; +export { diffJson, diffJsonAdvanced, applyJsonDiff } from './data/jsonDiff.js'; + +/** + * Flatten/unflatten nested structures. + */ +export { flatten, unflatten } from './data/flatten.js'; + +export type { FlattenOptions, UnflattenOptions } from './data/flatten.js'; + +/** + * Pagination helper for slicing arrays with metadata. + */ +export { paginate } from './data/pagination.js'; + +export type { PaginateOptions, PaginationResult, PaginationMetadata } from './data/pagination.js'; /** * JSON diff related type exports. @@ -758,6 +956,7 @@ export type { JsonPathSegment, JsonPrimitive, JsonValue, + DiffJsonAdvancedOptions, } from './data/jsonDiff.js'; // ============================================================================ diff --git a/src/search/boyerMoore.ts b/src/search/boyerMoore.ts new file mode 100644 index 0000000..4c06d68 --- /dev/null +++ b/src/search/boyerMoore.ts @@ -0,0 +1,87 @@ +export interface BoyerMooreOptions { + text: string; + pattern: string; + caseSensitive?: boolean; +} + +export function boyerMooreSearch(options: BoyerMooreOptions): number[] { + const pattern = options.caseSensitive ? options.pattern : options.pattern.toLowerCase(); + const text = options.caseSensitive ? options.text : options.text.toLowerCase(); + + if (pattern.length === 0) { + return Array.from({ length: text.length + 1 }, (_, index) => index); + } + if (pattern.length > text.length) { + return []; + } + + const badChar = buildBadCharacterTable(pattern); + const goodSuffix = buildGoodSuffixTable(pattern); + const matches: number[] = []; + + let shift = 0; + while (shift <= text.length - pattern.length) { + let index = pattern.length - 1; + while (index >= 0 && pattern[index] === text[shift + index]) { + index -= 1; + } + if (index < 0) { + matches.push(shift); + shift += goodSuffix[0]; + } else { + const badCharShift = index - (badChar.get(text[shift + index]) ?? -1); + const goodSuffixShift = goodSuffix[index]; + shift += Math.max(1, Math.max(badCharShift, goodSuffixShift)); + } + } + + return matches; +} + +function buildBadCharacterTable(pattern: string): Map { + const table = new Map(); + for (let i = 0; i < pattern.length; i += 1) { + table.set(pattern[i], i); + } + return table; +} + +function buildGoodSuffixTable(pattern: string): number[] { + const length = pattern.length; + const table = new Array(length).fill(0); + const suffixes = buildSuffixes(pattern); + + for (let i = 0; i < length; i += 1) { + table[i] = length - suffixes[0]; + } + for (let i = 0; i < length - 1; i += 1) { + const j = length - 1 - suffixes[i]; + table[j] = length - 1 - i; + } + + return table; +} + +function buildSuffixes(pattern: string): number[] { + const length = pattern.length; + const suffixes = new Array(length).fill(0); + suffixes[length - 1] = length; + let g = length - 1; + let f = 0; + for (let i = length - 2; i >= 0; i -= 1) { + if (i > g && suffixes[i + length - 1 - f] < i - g) { + suffixes[i] = suffixes[i + length - 1 - f]; + } else { + if (i < g) { + g = i; + } + f = i; + while (g >= 0 && pattern[g] === pattern[g + length - 1 - f]) { + g -= 1; + } + suffixes[i] = f - g; + } + } + return suffixes; +} + diff --git a/src/search/kmp.ts b/src/search/kmp.ts new file mode 100644 index 0000000..8001d9d --- /dev/null +++ b/src/search/kmp.ts @@ -0,0 +1,55 @@ +export interface KMPSearchOptions { + text: string; + pattern: string; + caseSensitive?: boolean; +} + +export function kmpSearch(options: KMPSearchOptions): number[] { + const pattern: string = options.caseSensitive ? options.pattern : options.pattern.toLowerCase(); + const text: string = options.caseSensitive ? options.text : options.text.toLowerCase(); + + if (pattern.length === 0) { + return Array.from({ length: options.text.length + 1 }, (_, index): number => index); + } + + const lps = buildLps(pattern); + const matches: number[] = []; + + let i = 0; + let j = 0; + while (i < text.length) { + if (pattern[j] === text[i]) { + i += 1; + j += 1; + if (j === pattern.length) { + matches.push(i - j); + j = lps[j - 1]; + } + } else if (j > 0) { + j = lps[j - 1]; + } else { + i += 1; + } + } + + return matches; +} + +function buildLps(pattern: string): number[] { + const lps = new Array(pattern.length).fill(0); + let length = 0; + let i = 1; + while (i < pattern.length) { + if (pattern[i] === pattern[length]) { + length += 1; + lps[i] = length; + i += 1; + } else if (length > 0) { + length = lps[length - 1]; + } else { + lps[i] = 0; + i += 1; + } + } + return lps; +} diff --git a/src/search/lcs.ts b/src/search/lcs.ts new file mode 100644 index 0000000..d925bd9 --- /dev/null +++ b/src/search/lcs.ts @@ -0,0 +1,89 @@ +export interface LCSOptions { + a: string; + b: string; +} + +export interface LCSResult { + length: number; + sequence: string; +} + +export interface DiffOp { + type: 'equal' | 'insert' | 'delete'; + value: string; +} + +export function longestCommonSubsequence(options: LCSOptions): LCSResult { + const { a, b } = options; + const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0)); + + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + let i = a.length; + let j = b.length; + const result: string[] = []; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + result.push(a[i - 1]); + i -= 1; + j -= 1; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i -= 1; + } else { + j -= 1; + } + } + + return { length: dp[a.length][b.length], sequence: result.reverse().join('') }; +} + +export function diffStrings(options: LCSOptions): DiffOp[] { + const { a, b } = options; + const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0)); + + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + if (a[i] === b[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + const ops: DiffOp[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + ops.push({ type: 'equal', value: a[i] }); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ type: 'delete', value: a[i] }); + i += 1; + } else { + ops.push({ type: 'insert', value: b[j] }); + j += 1; + } + } + while (i < a.length) { + ops.push({ type: 'delete', value: a[i] }); + i += 1; + } + while (j < b.length) { + ops.push({ type: 'insert', value: b[j] }); + j += 1; + } + + return ops; +} + diff --git a/src/search/rabinKarp.ts b/src/search/rabinKarp.ts new file mode 100644 index 0000000..d175848 --- /dev/null +++ b/src/search/rabinKarp.ts @@ -0,0 +1,151 @@ +export interface RabinKarpOptions { + text: string; + patterns: ReadonlyArray; + prime?: number; + base?: number; + caseSensitive?: boolean; +} + +export function rabinKarp(options: RabinKarpOptions): Record { + validateOptions(options); + const base = options.base ?? 257; + const prime = options.prime ?? 1_000_000_007; + const texts = options.caseSensitive ? options.text : options.text.toLowerCase(); + const patterns = options.caseSensitive + ? options.patterns + : options.patterns.map((pattern) => pattern.toLowerCase()); + + const uniqueLengths = new Set(patterns.map((pattern) => pattern.length).filter((length) => length > 0)); + const results: Record = {}; + for (const pattern of options.patterns) { + results[pattern] = []; + } + + const groupedPatterns = groupPatterns(patterns, options.patterns); + + for (const length of uniqueLengths) { + const group = groupedPatterns.get(length); + if (!group) { + continue; + } + const power = modPow(base, length - 1, prime); + const targetHashes = new Map(); + for (const { normalized, original } of group) { + const hash = rollingHash(normalized, base, prime); + const list = targetHashes.get(hash) ?? []; + list.push(original); + targetHashes.set(hash, list); + } + + let windowHash = rollingHash(texts.slice(0, length), base, prime); + compareWindow(0, length, windowHash, targetHashes, options.text, options.caseSensitive, results); + + for (let i = length; i < texts.length; i += 1) { + const outgoing = texts.charCodeAt(i - length); + const incoming = texts.charCodeAt(i); + windowHash = roll(windowHash, outgoing, incoming, base, prime, power); + compareWindow(i - length + 1, length, windowHash, targetHashes, options.text, options.caseSensitive, results); + } + } + + if (patterns.some((pattern) => pattern.length === 0)) { + const allPositions = Array.from({ length: options.text.length + 1 }, (_, index) => index); + for (const pattern of options.patterns) { + if (pattern.length === 0) { + results[pattern] = allPositions.slice(); + } + } + } + + return results; +} + +function compareWindow( + start: number, + size: number, + hash: number, + targetHashes?: Map, + text?: string, + caseSensitive?: boolean, + results?: Record +): void { + if (!targetHashes || !text || !results) { + return; + } + const candidates = targetHashes.get(hash); + if (!candidates) { + return; + } + const fragment = text.substr(start, size); + for (const pattern of candidates) { + if (match(fragment, pattern, caseSensitive ?? true)) { + results[pattern].push(start); + } + } +} + +function groupPatterns( + patterns: ReadonlyArray, + originals: ReadonlyArray +): Map> { + const groups = new Map>(); + patterns.forEach((pattern, index) => { + if (pattern.length === 0) { + return; + } + const list = groups.get(pattern.length) ?? []; + list.push({ normalized: pattern, original: originals[index] }); + groups.set(pattern.length, list); + }); + return groups; +} + +function rollingHash(value: string, base: number, prime: number): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * base + value.charCodeAt(i)) % prime; + } + return hash; +} + +function roll(hash: number, outgoing: number, incoming: number, base: number, prime: number, power: number): number { + let next = (hash + prime - (outgoing * power) % prime) % prime; + next = (next * base + incoming) % prime; + return next; +} + +function modPow(base: number, exponent: number, prime: number): number { + let result = 1; + let b = base % prime; + let e = exponent; + while (e > 0) { + if (e & 1) { + result = (result * b) % prime; + } + b = (b * b) % prime; + e >>= 1; + } + return result; +} + +function match(fragment: string, pattern: string, caseSensitive: boolean): boolean { + if (!caseSensitive) { + return fragment.toLowerCase() === pattern.toLowerCase(); + } + return fragment === pattern; +} + +function validateOptions(options: RabinKarpOptions): void { + if (typeof options.text !== 'string') { + throw new Error('text must be a string.'); + } + if (!Array.isArray(options.patterns) || options.patterns.length === 0) { + throw new Error('patterns must contain at least one pattern.'); + } + if (options.base !== undefined && options.base <= 1) { + throw new Error('base must be greater than 1.'); + } + if (options.prime !== undefined && options.prime <= 0) { + throw new Error('prime must be positive.'); + } +} diff --git a/src/search/suffixArray.ts b/src/search/suffixArray.ts new file mode 100644 index 0000000..9c15a62 --- /dev/null +++ b/src/search/suffixArray.ts @@ -0,0 +1,82 @@ +export interface SuffixArrayOptions { + text: string; + caseSensitive?: boolean; +} + +export interface SuffixArrayResult { + suffixArray: number[]; + lcpArray: number[]; +} + +export function buildSuffixArray(options: SuffixArrayOptions): SuffixArrayResult { + const text = options.caseSensitive ? options.text : options.text.toLowerCase(); + const n = text.length; + const suffixArray = new Array(n); + const ranks = new Array(n); + const temp = new Array(n); + + for (let i = 0; i < n; i += 1) { + suffixArray[i] = i; + ranks[i] = text.charCodeAt(i); + } + + for (let k = 1; k < n; k <<= 1) { + suffixArray.sort((a, b) => { + if (ranks[a] !== ranks[b]) { + return ranks[a] - ranks[b]; + } + const rankA = a + k < n ? ranks[a + k] : -1; + const rankB = b + k < n ? ranks[b + k] : -1; + return rankA - rankB; + }); + + temp[suffixArray[0]] = 0; + for (let i = 1; i < n; i += 1) { + temp[suffixArray[i]] = temp[suffixArray[i - 1]] + (compareSuffix(suffixArray[i - 1], suffixArray[i], k) ? 1 : 0); + } + for (let i = 0; i < n; i += 1) { + ranks[i] = temp[i]; + } + if (ranks[suffixArray[n - 1]] === n - 1) { + break; + } + } + + const lcpArray = buildLCPArray(text, suffixArray); + return { suffixArray, lcpArray }; + + function compareSuffix(a: number, b: number, k: number): boolean { + if (ranks[a] !== ranks[b]) { + return true; + } + const rankA = a + k < n ? ranks[a + k] : -1; + const rankB = b + k < n ? ranks[b + k] : -1; + return rankA !== rankB; + } +} + +function buildLCPArray(text: string, suffixArray: number[]): number[] { + const n = text.length; + const rank = new Array(n); + for (let i = 0; i < n; i += 1) { + rank[suffixArray[i]] = i; + } + const lcp = new Array(Math.max(0, n - 1)).fill(0); + let k = 0; + for (let i = 0; i < n; i += 1) { + if (rank[i] === n - 1) { + k = 0; + continue; + } + const j = suffixArray[rank[i] + 1]; + while (i + k < n && j + k < n && text[i + k] === text[j + k]) { + k += 1; + } + lcp[rank[i]] = k; + if (k > 0) { + k -= 1; + } + } + return lcp; +} + diff --git a/tests/flatten.test.ts b/tests/flatten.test.ts new file mode 100644 index 0000000..fba4266 --- /dev/null +++ b/tests/flatten.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { flatten, unflatten } from '../src/index.js'; + +describe('flatten/unflatten', () => { + it('flattens nested objects with arrays', () => { + const input = { user: { name: 'Ada', tags: ['researcher', 'engineer'] } }; + const result = flatten(input); + expect(result).toEqual({ + 'user.name': 'Ada', + 'user.tags.0': 'researcher', + 'user.tags.1': 'engineer', + }); + }); + + it('round-trips via unflatten', () => { + const entries = { + 'config.theme': 'dark', + 'config.layout.columns': 3, + 'config.features.0': 'beta', + }; + const restored = unflatten(entries); + expect(restored).toEqual({ + config: { + theme: 'dark', + layout: { columns: 3 }, + features: ['beta'], + }, + }); + }); +}); + diff --git a/tests/fsm.test.ts b/tests/fsm.test.ts new file mode 100644 index 0000000..b6da8cc --- /dev/null +++ b/tests/fsm.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createFSM } from '../src/index.js'; + +describe('createFSM', () => { + it('transitions states based on events and conditions', () => { + const context = { energy: 0 }; + const enterIdle = vi.fn(); + const enterActive = vi.fn(); + const exitActive = vi.fn(); + + const fsm = createFSM({ + context, + initial: 'idle', + states: [ + { id: 'idle', onEnter: enterIdle }, + { + id: 'active', + onEnter: () => { + enterActive(); + }, + onExit: () => { + exitActive(); + }, + onUpdate: (ctx, delta) => { + ctx.energy += delta; + }, + }, + ], + transitions: [ + { from: 'idle', to: 'active', event: 'start' }, + { + from: 'active', + to: 'idle', + event: 'stop', + condition: (ctx) => ctx.energy >= 1, + }, + ], + }); + + expect(fsm.getState()).toBe('idle'); + expect(enterIdle).toHaveBeenCalledTimes(1); + + expect(fsm.send('start', { type: 'start' })).toBe(true); + expect(fsm.getState()).toBe('active'); + expect(enterActive).toHaveBeenCalledTimes(1); + + fsm.update(0.5); + expect(context.energy).toBeCloseTo(0.5, 5); + expect(fsm.send('stop', { type: 'stop' })).toBe(false); + + fsm.update(0.6); + expect(context.energy).toBeCloseTo(1.1, 5); + expect(fsm.send('stop', { type: 'stop' })).toBe(true); + expect(exitActive).toHaveBeenCalledTimes(1); + expect(fsm.getState()).toBe('idle'); + }); + + it('supports reset to explicit state', () => { + const context = { status: 'idle' }; + const fsm = createFSM({ + context, + initial: 'idle', + states: [ + { + id: 'idle', + onEnter: (ctx) => { + ctx.status = 'idle'; + }, + }, + { + id: 'busy', + onEnter: (ctx) => { + ctx.status = 'busy'; + }, + }, + ], + transitions: [{ from: 'idle', to: 'busy', event: 'advance' }], + }); + + fsm.send('advance', { type: 'advance' }); + expect(fsm.getState()).toBe('busy'); + expect(context.status).toBe('busy'); + + fsm.reset('idle'); + expect(fsm.getState()).toBe('idle'); + expect(context.status).toBe('idle'); + }); +}); diff --git a/tests/genetic.test.ts b/tests/genetic.test.ts new file mode 100644 index 0000000..e134c36 --- /dev/null +++ b/tests/genetic.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { createGeneticAlgorithm } from '../src/index.js'; + +const TARGET = '1111'; + +describe('createGeneticAlgorithm', () => { + it('evolves population toward higher fitness with elitism', () => { + let randomIndex = 0; + const sequence = [0.1, 0.6, 0.2, 0.8, 0.4, 0.9, 0.3, 0.7]; + const random = () => { + const value = sequence[randomIndex % sequence.length]; + randomIndex += 1; + return value; + }; + + const initial = ['0000', '0101', '1010', '0011']; + + const ga = createGeneticAlgorithm({ + population: initial, + fitness: score, + mutate: (individual) => mutate(individual), + crossover: (a, b, rand) => crossover(a, b, rand), + elitism: 1, + random, + }); + + expect(ga.getBest().fitness).toBeLessThan(TARGET.length); + + ga.run(3); + + const best = ga.getBest(); + expect(best.individual).toBe(TARGET); + expect(best.fitness).toBe(TARGET.length); + expect(ga.getGeneration()).toBe(3); + }); +}); + +function score(individual: string): number { + let total = 0; + for (let index = 0; index < TARGET.length; index += 1) { + if (individual[index] === TARGET[index]) { + total += 1; + } + } + return total; +} + +function mutate(individual: string): string { + if (individual === TARGET) { + return individual; + } + return TARGET; +} + +function crossover(a: string, b: string, rand: () => number): string { + const midpoint = Math.floor(rand() * TARGET.length); + return a.slice(0, midpoint) + b.slice(midpoint); +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 71e90e4..b02d902 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -100,6 +100,20 @@ describe('package entry point', () => { | 'fisherYatesShuffle' >(); + expectTypeOf>().toEqualTypeOf< + | 'fuzzySearch' + | 'fuzzyScore' + | 'Trie' + | 'binarySearch' + | 'levenshteinDistance' + | 'kmpSearch' + | 'rabinKarp' + | 'boyerMooreSearch' + | 'buildSuffixArray' + | 'longestCommonSubsequence' + | 'diffStrings' + >(); + expectTypeOf>().toEqualTypeOf< | 'createDeltaTimeManager' | 'createFixedTimestepLoop' @@ -116,6 +130,25 @@ describe('package entry point', () => { | 'createCooldownController' | 'createQuestMachine' | 'computeLightingGrid' + | 'createWaveSpawner' + | 'createSoundManager' + | 'createInputManager' + | 'createSaveManager' + | 'createScreenTransition' + >(); + + expectTypeOf>().toEqualTypeOf< + | 'seek' + | 'flee' + | 'pursue' + | 'wander' + | 'arrive' + | 'updateBoids' + | 'BehaviorTree' + | 'rvoStep' + | 'createFSM' + | 'createGeneticAlgorithm' + | 'computeInfluenceMap' >(); }); }); diff --git a/tests/influenceMap.test.ts b/tests/influenceMap.test.ts new file mode 100644 index 0000000..0d5b6a9 --- /dev/null +++ b/tests/influenceMap.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { computeInfluenceMap } from '../src/index.js'; + +describe('computeInfluenceMap', () => { + it('accumulates influence with linear falloff and decay', () => { + const result = computeInfluenceMap({ + width: 3, + height: 3, + cellSize: 1, + sources: [{ position: { x: 1, y: 1 }, strength: 9, radius: 2, falloff: 'linear' }], + decay: 0.1, + }); + + expect(result.width).toBe(3); + expect(result.height).toBe(3); + expect(result.values).toHaveLength(9); + expect(result.values[4]).toBeGreaterThan(5); + expect(result.values[4]).toBeLessThanOrEqual(9); + expect(result.values[0]).toBeGreaterThan(0); + }); + + it('supports inverse falloff and obstacles', () => { + const map = computeInfluenceMap({ + width: 3, + height: 3, + cellSize: 1, + sources: [{ position: { x: 0, y: 0 }, strength: 4, radius: 3, falloff: 'inverse' }], + obstacles: (x, y) => x === 1 && y === 0, + }); + + expect(map.values[0]).toBeGreaterThan(3.5); + expect(map.values[0]).toBeLessThanOrEqual(8); + expect(map.values[1]).toBe(0); + expect(map.values[3]).toBeGreaterThan(map.values[4]); + }); + + it('validates decay range and falloff values', () => { + expect(() => + computeInfluenceMap({ + width: 2, + height: 2, + decay: 1.5, + sources: [{ position: { x: 0, y: 0 }, strength: 1 }], + }) + ).toThrow('decay must be in the range [0, 1].'); + + expect(() => + computeInfluenceMap({ + width: 2, + height: 2, + sources: [{ position: { x: 0, y: 0 }, strength: 1, falloff: 'quadratic' as 'linear' }], + }) + ).toThrow('Source falloff must be linear, inverse, or constant when provided.'); + }); +}); diff --git a/tests/inputManager.test.ts b/tests/inputManager.test.ts new file mode 100644 index 0000000..492ee98 --- /dev/null +++ b/tests/inputManager.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { createInputManager } from '../src/index.js'; + +describe('createInputManager', () => { + it('tracks digital inputs and remaps actions', () => { + let now = 0; + const manager = createInputManager({ + getTime: () => now, + actions: [ + { + id: 'jump', + bindings: [ + { device: 'keyboard', key: 'Space' }, + { device: 'gamepad-button', button: 0 }, + ], + }, + { id: 'fire', bindings: [{ device: 'mouse', button: 0 }] }, + ], + }); + + expect(manager.isActive('jump')).toBe(false); + + manager.handleKeyEvent({ type: 'down', key: 'Space', time: now }); + expect(manager.isActive('jump')).toBe(true); + expect(manager.getValue('jump')).toBe(1); + + manager.handleKeyEvent({ type: 'up', key: 'Space', time: now }); + expect(manager.isActive('jump')).toBe(false); + + manager.handlePointerEvent({ type: 'down', button: 0, time: now }); + expect(manager.isActive('fire')).toBe(true); + manager.handlePointerEvent({ type: 'up', button: 0, time: now }); + + manager.setBindings('jump', [{ device: 'keyboard', key: 'KeyZ' }]); + manager.handleKeyEvent({ type: 'down', key: 'Space', time: now }); + expect(manager.isActive('jump')).toBe(false); + + now = 1; + manager.handleKeyEvent({ type: 'down', key: 'KeyZ', time: now }); + expect(manager.isActive('jump')).toBe(true); + expect(manager.getState('jump')?.changedAt).toBe(now); + + manager.reset(); + expect(manager.isActive('jump')).toBe(false); + }); + + it('handles analog gamepad axis updates with thresholds', () => { + let now = 0; + const manager = createInputManager({ + getTime: () => now, + defaultAxisThreshold: 0.2, + actions: [ + { + id: 'move-horizontal', + type: 'analog', + bindings: [{ device: 'gamepad-axis', axis: 0, direction: 'both', threshold: 0.25 }], + }, + ], + }); + + manager.handleGamepadAxis({ axis: 0, value: 0.1, time: now }); + expect(manager.isActive('move-horizontal')).toBe(false); + expect(manager.getValue('move-horizontal')).toBe(0); + + now = 1; + manager.handleGamepadAxis({ axis: 0, value: 0.6, time: now }); + expect(manager.isActive('move-horizontal')).toBe(true); + expect(manager.getValue('move-horizontal')).toBeCloseTo(0.6, 5); + expect(manager.getState('move-horizontal')?.changedAt).toBe(now); + + now = 2; + manager.handleGamepadAxis({ axis: 0, value: -0.7, time: now }); + expect(manager.getValue('move-horizontal')).toBeCloseTo(-0.7, 5); + + now = 3; + manager.handleGamepadAxis({ axis: 0, value: 0.05, time: now }); + expect(manager.isActive('move-horizontal')).toBe(false); + expect(manager.getValue('move-horizontal')).toBe(0); + }); +}); diff --git a/tests/jsonDiff.test.ts b/tests/jsonDiff.test.ts index 709e988..1421aef 100644 --- a/tests/jsonDiff.test.ts +++ b/tests/jsonDiff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { applyJsonDiff, diffJson } from '../src/data/jsonDiff.js'; +import { applyJsonDiff, diffJson, diffJsonAdvanced } from '../src/data/jsonDiff.js'; describe('diffJson', () => { it('produces replace operations for primitive changes at root', () => { @@ -57,4 +57,14 @@ describe('diffJson', () => { expect(diff).toEqual([]); expect(applyJsonDiff(value, diff)).toEqual(value); }); + + it('supports ignoreKeys and path filters', () => { + const previous = { status: 'idle', metrics: { cpu: 10, mem: 20 } }; + const next = { status: 'running', metrics: { cpu: 15, mem: 20 } }; + const diff = diffJsonAdvanced(previous, next, { + ignoreKeys: ['mem'], + pathFilter: (path) => !(path.length === 1 && path[0] === 'status'), + }); + expect(diff).toEqual([{ op: 'replace', path: ['metrics', 'cpu'], value: 15 }]); + }); }); diff --git a/tests/pagination.test.ts b/tests/pagination.test.ts new file mode 100644 index 0000000..786086f --- /dev/null +++ b/tests/pagination.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { paginate } from '../src/index.js'; + +describe('paginate', () => { + it('returns items for requested page with metadata', () => { + const items = Array.from({ length: 12 }, (_, index) => index + 1); + const result = paginate({ items, page: 2, pageSize: 5 }); + expect(result.items).toEqual([6, 7, 8, 9, 10]); + expect(result.metadata).toMatchObject({ + page: 2, + pageSize: 5, + totalItems: 12, + totalPages: 3, + hasPrevious: true, + hasNext: true, + }); + }); + + it('clamps page within valid range and handles small collections', () => { + const items = [1, 2, 3]; + const result = paginate({ items, page: 10, pageSize: 2 }); + expect(result.items).toEqual([3]); + expect(result.metadata.page).toBe(2); + expect(result.metadata.hasNext).toBe(false); + }); + + it('validates options and throws on invalid page inputs', () => { + const items = [1, 2, 3]; + expect(() => paginate({ items, page: 0, pageSize: 2 })).toThrow('page must be a positive integer'); + expect(() => paginate({ items, page: 1, pageSize: 0 })).toThrow('pageSize must be a positive integer'); + }); +}); diff --git a/tests/saveManager.test.ts b/tests/saveManager.test.ts new file mode 100644 index 0000000..17d7680 --- /dev/null +++ b/tests/saveManager.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { createSaveManager, createMemorySaveStorage } from '../src/index.js'; + +interface State { + level: number; + coins: number; +} + +function computeChecksum(raw: string): string { + let hash = 2166136261; + for (let index = 0; index < raw.length; index += 1) { + hash ^= raw.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} + +describe('createSaveManager', () => { + it('saves, loads, and overwrites slots with metadata updates', () => { + let now = 0; + const manager = createSaveManager({ + prefix: 'test', + version: 1, + getTime: () => now, + }); + + const first = manager.save('slot-1', { level: 3, coins: 120 }); + expect(first.metadata.slotId).toBe('slot-1'); + expect(first.metadata.version).toBe(1); + + const loadResult = manager.load('slot-1'); + expect(loadResult.ok).toBe(true); + expect(loadResult.data).toMatchObject({ level: 3, coins: 120 }); + + now = 10; + const overwrite = manager.save('slot-1', { level: 4, coins: 180 }); + expect(overwrite.overwritten?.updatedAt).toBe(first.metadata.updatedAt); + expect(overwrite.metadata.updatedAt).toBe(now); + + expect(manager.list()).toHaveLength(1); + expect(manager.get('slot-1')?.checksum).toBe(overwrite.metadata.checksum); + + const removed = manager.delete('slot-1'); + expect(removed?.slotId).toBe('slot-1'); + expect(manager.list()).toHaveLength(0); + }); + + it('evicts oldest slots when exceeding maxSlots', () => { + let now = 0; + const storage = createMemorySaveStorage(); + const manager = createSaveManager({ + prefix: 'campaign', + maxSlots: 2, + storage, + getTime: () => now, + }); + + manager.save('slot-a', { level: 1, coins: 10 }); + now = 1; + manager.save('slot-b', { level: 2, coins: 20 }); + now = 2; + const result = manager.save('slot-c', { level: 3, coins: 30 }); + + expect(result.evicted).toBeDefined(); + expect(result.evicted?.[0].slotId).toBe('slot-a'); + expect(manager.get('slot-a')).toBeNull(); + expect(manager.list().map((slot) => slot.slotId)).toEqual(['slot-c', 'slot-b']); + }); + + it('detects corrupted payloads and parse errors', () => { + const storage = createMemorySaveStorage(); + const manager = createSaveManager({ + prefix: 'corrupt', + storage, + }); + + manager.save('slot-1', { level: 2, coins: 50 }); + + const key = 'corrupt::slot-1'; + const raw = storage.getItem(key); + expect(raw).not.toBeNull(); + const payload = JSON.parse(raw as string) as { data: string; checksum: string }; + + // Corrupt data without updating checksum – should fail integrity. + payload.data = JSON.stringify({ level: 99, coins: 9999 }); + storage.setItem(key, JSON.stringify(payload)); + const corrupted = manager.load('slot-1'); + expect(corrupted.ok).toBe(false); + expect(corrupted.error).toBe('corrupted'); + + // Adjust checksum but provide invalid JSON for the deserializer – should fail parsing. + payload.data = '{invalid json'; + payload.checksum = computeChecksum(payload.data); + storage.setItem(key, JSON.stringify(payload)); + const parseFailure = manager.load('slot-1'); + expect(parseFailure.ok).toBe(false); + expect(parseFailure.error).toBe('parse-error'); + + expect(manager.clear()).toHaveLength(1); + expect(manager.list()).toHaveLength(0); + }); +}); diff --git a/tests/screenTransitions.test.ts b/tests/screenTransitions.test.ts new file mode 100644 index 0000000..6e9af30 --- /dev/null +++ b/tests/screenTransitions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeFade, + computeHorizontalWipe, + computeLetterbox, + createScreenTransition, +} from '../src/index.js'; + +describe('screen transitions', () => { + it('progresses through phases and respects hold duration', () => { + const transition = createScreenTransition({ durationIn: 1, hold: 0.5, durationOut: 1 }); + transition.start(); + + // Start phase in + let state = transition.getState(); + expect(state.phase).toBe('in'); + expect(state.value).toBe(0); + + state = transition.update(0.5); + expect(state.phase).toBe('in'); + expect(state.value).toBeCloseTo(0.5, 5); + expect(computeFade(state).opacity).toBeCloseTo(0.5, 5); + + state = transition.update(0.5); + expect(state.phase).toBe('hold'); + expect(state.value).toBe(1); + + state = transition.update(0.5); + expect(state.phase).toBe('out'); + expect(state.value).toBeCloseTo(1, 5); + + state = transition.update(0.5); + expect(state.phase).toBe('out'); + expect(state.value).toBeCloseTo(0.5, 5); + + state = transition.update(0.5); + expect(state.phase).toBe('completed'); + expect(state.value).toBe(0); + expect(transition.isCompleted()).toBe(true); + }); + + it('computes wipe and letterbox values and can reset', () => { + const transition = createScreenTransition({ durationIn: 0.8, durationOut: 0.8 }); + transition.start(); + + transition.update(0.4); + let state = transition.getState(); + const wipe = computeHorizontalWipe(state, 'right'); + expect(wipe.direction).toBe('right'); + expect(wipe.offset).toBeCloseTo(state.value, 5); + + const bars = computeLetterbox(state, 200); + expect(bars.barSize).toBeGreaterThan(0); + + transition.update(2); + expect(transition.isCompleted()).toBe(true); + + transition.reset(); + state = transition.getState(); + expect(state.phase).toBe('idle'); + expect(state.value).toBe(0); + expect(transition.isActive()).toBe(false); + }); +}); diff --git a/tests/search.test.ts b/tests/search.test.ts index dacf632..c873b18 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest'; import { binarySearch } from '../src/search/binarySearch.js'; import { fuzzySearch, fuzzyScore } from '../src/search/fuzzy.js'; import { Trie } from '../src/search/trie.js'; +import { kmpSearch } from '../src/search/kmp.js'; +import { rabinKarp } from '../src/search/rabinKarp.js'; +import { boyerMooreSearch } from '../src/search/boyerMoore.js'; +import { buildSuffixArray } from '../src/search/suffixArray.js'; +import { diffStrings, longestCommonSubsequence } from '../src/search/lcs.js'; describe('binarySearch', () => { it('finds elements in sorted numeric arrays', () => { @@ -57,3 +62,91 @@ describe('Trie', () => { expect(() => trie.insert(123 as unknown as string)).toThrow(TypeError); }); }); + +describe('kmpSearch', () => { + it('finds all occurrences of a pattern', () => { + expect(kmpSearch({ text: 'aaaaa', pattern: 'aa' })).toEqual([0, 1, 2, 3]); + }); + + it('supports case-insensitive matching', () => { + expect(kmpSearch({ text: 'Hello World', pattern: 'world', caseSensitive: false })).toEqual([6]); + }); + + it('returns every position for empty pattern', () => { + expect(kmpSearch({ text: 'abc', pattern: '' })).toEqual([0, 1, 2, 3]); + }); +}); + +describe('rabinKarp', () => { + it('finds multiple patterns simultaneously', () => { + const matches = rabinKarp({ text: 'abracadabra', patterns: ['abra', 'cad'] }); + expect(matches['abra']).toEqual([0, 7]); + expect(matches['cad']).toEqual([4]); + }); + + it('supports case-insensitive matching and empty patterns', () => { + const matches = rabinKarp({ text: 'ABCabc', patterns: ['abc', 'ABC', ''], caseSensitive: false }); + expect(matches['abc']).toEqual([0, 3]); + expect(matches['ABC']).toEqual([0, 3]); + expect(matches['']).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); +}); + +describe('boyerMooreSearch', () => { + it('finds single pattern occurrences efficiently', () => { + expect(boyerMooreSearch({ text: 'abracadabra', pattern: 'abra' })).toEqual([0, 7]); + }); + + it('handles case-insensitive searches and empty pattern', () => { + expect(boyerMooreSearch({ text: 'Hello', pattern: 'hello', caseSensitive: false })).toEqual([0]); + expect(boyerMooreSearch({ text: 'abc', pattern: '' })).toEqual([0, 1, 2, 3]); + }); +}); + +describe('buildSuffixArray', () => { + it('produces suffix and LCP arrays', () => { + const { suffixArray, lcpArray } = buildSuffixArray({ text: 'banana' }); + expect(suffixArray).toEqual([5, 3, 1, 0, 4, 2]); + expect(lcpArray).toEqual([1, 3, 0, 0, 2]); + }); + + it('supports case-insensitive construction', () => { + const lower = buildSuffixArray({ text: 'AbC', caseSensitive: true }); + const upper = buildSuffixArray({ text: 'abc', caseSensitive: false }); + expect(lower.suffixArray).not.toEqual(upper.suffixArray); + }); +}); + +describe('longestCommonSubsequence', () => { + it('computes LCS length and sequence', () => { + const result = longestCommonSubsequence({ a: 'ABCBDAB', b: 'BDCABA' }); + expect(result.length).toBe(4); + expect(isSubsequence(result.sequence, 'ABCBDAB')).toBe(true); + expect(isSubsequence(result.sequence, 'BDCABA')).toBe(true); + }); +}); + +describe('diffStrings', () => { + it('emits diff operations between two strings', () => { + const diff = diffStrings({ a: 'abc', b: 'axbc' }); + expect(diff).toEqual([ + { type: 'equal', value: 'a' }, + { type: 'insert', value: 'x' }, + { type: 'equal', value: 'b' }, + { type: 'equal', value: 'c' }, + ]); + }); +}); + +function isSubsequence(subsequence: string, text: string): boolean { + let index = 0; + for (const char of text) { + if (char === subsequence[index]) { + index += 1; + if (index === subsequence.length) { + return true; + } + } + } + return subsequence.length === 0; +} diff --git a/tests/soundManager.test.ts b/tests/soundManager.test.ts new file mode 100644 index 0000000..6219267 --- /dev/null +++ b/tests/soundManager.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { createSoundManager } from '../src/index.js'; + +describe('createSoundManager', () => { + it('enforces channel capacity and preempts lower priority sounds', () => { + let now = 0; + const manager = createSoundManager({ + maxChannels: 2, + getTime: () => now, + }); + + const first = manager.play({ soundId: 'step-1', duration: 1, priority: 1 }); + const second = manager.play({ soundId: 'step-2', duration: 1, priority: 2 }); + + expect(first.accepted).toBe(true); + expect(second.accepted).toBe(true); + + const rejected = manager.play({ soundId: 'step-3', duration: 1, priority: 1 }); + expect(rejected.accepted).toBe(false); + expect(rejected.reason).toBe('channel-limit'); + + const promoted = manager.play({ soundId: 'step-4', duration: 1, priority: 5 }); + expect(promoted.accepted).toBe(true); + expect(promoted.evicted?.soundId).toBe('step-1'); + expect(manager.getActive()).toHaveLength(2); + + const stopped = manager.stop(second.handle!.handleId); + expect(stopped?.soundId).toBe('step-2'); + expect(manager.getActive()).toHaveLength(1); + + now = 2; + const expired = manager.update(); + expect(expired).toHaveLength(1); + expect(manager.getActive()).toHaveLength(0); + }); + + it('applies per-channel limits and allows higher priority overrides', () => { + let now = 0; + const manager = createSoundManager({ + maxChannels: 4, + channelLimits: { music: 1, sfx: 2 }, + getTime: () => now, + }); + + const music = manager.play({ soundId: 'bgm', channel: 'music', duration: 10, priority: 1 }); + expect(music.accepted).toBe(true); + + const deniedMusic = manager.play({ soundId: 'boss-intro', channel: 'music', duration: 3, priority: 1 }); + expect(deniedMusic.accepted).toBe(false); + + const override = manager.play({ soundId: 'boss-intro', channel: 'music', duration: 3, priority: 10 }); + expect(override.accepted).toBe(true); + expect(override.evicted?.soundId).toBe('bgm'); + + manager.play({ soundId: 'hit', channel: 'sfx', duration: 1 }); + manager.play({ soundId: 'jump', channel: 'sfx', duration: 1 }); + const extraSfx = manager.play({ soundId: 'coin', channel: 'sfx', duration: 1 }); + expect(extraSfx.accepted).toBe(false); + + now = 2; + const finished = manager.update(); + expect(finished.length).toBeGreaterThan(0); + expect(manager.getActive().length).toBeLessThanOrEqual(2); + + const removed = manager.reset(); + expect(removed.length).toBeGreaterThan(0); + expect(manager.getActive()).toHaveLength(0); + }); +}); diff --git a/tests/waveSpawner.test.ts b/tests/waveSpawner.test.ts new file mode 100644 index 0000000..6a00b5b --- /dev/null +++ b/tests/waveSpawner.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createWaveSpawner } from '../src/index.js'; + +describe('createWaveSpawner', () => { + it('spawns waves with delays and intervals', () => { + const spawner = createWaveSpawner({ + waves: [ + { delay: 1, count: 2, interval: 0.5, template: 'grunt' }, + { delay: 2, count: 1, template: 'brute' }, + ], + }); + + let spawns = spawner.update(0.5); + expect(spawns).toHaveLength(0); + spawns = spawner.update(0.5); + expect(spawns).toHaveLength(1); + expect(spawns[0]).toMatchObject({ waveIndex: 0, entityIndex: 0, template: 'grunt' }); + + spawns = spawner.update(0.5); + expect(spawns).toHaveLength(1); + expect(spawns[0].entityIndex).toBe(1); + + spawns = spawner.update(2); + expect(spawns).toHaveLength(1); + expect(spawns[0]).toMatchObject({ waveIndex: 1, entityIndex: 0, template: 'brute' }); + expect(spawner.isFinished()).toBe(true); + }); + + it('loops waves when configured', () => { + const spawner = createWaveSpawner({ + loop: true, + waves: [{ delay: 0, count: 1, template: 'loop' }], + }); + + let spawns = spawner.update(0); + expect(spawns).toHaveLength(1); + expect(spawner.isFinished()).toBe(false); + + spawns = spawner.update(0); + expect(spawns).toHaveLength(1); + }); +});