coolneur 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coolneur contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
package/README.md CHANGED
@@ -1,3 +1,231 @@
1
- # Temporary Holding Version
1
+ # coolneur
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Use this when:** you need an in-memory cooldown manager for a JavaScript or TypeScript game, bot, API, or CLI. Define classic timers or multi-charge abilities, group related cooldowns into named streams, pause and resume state, and save or restore snapshots. `tryUse()` checks and consumes synchronously in one call. Zero runtime dependencies.
4
+
5
+ **Search terms:** cooldown, cooldown manager, rate limit, throttle, timer, ability cooldown, multi-charge cooldown, charge regeneration, scoped cooldown, user cooldown, guild cooldown, channel cooldown, command cooldown, JavaScript, TypeScript.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install coolneur
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```js
16
+ import { Coolneur } from 'coolneur';
17
+
18
+ let now = 0;
19
+ const coolneur = new Coolneur({ clock: () => now });
20
+ const player = coolneur.stream('player:42');
21
+ player.define('dash', { durationMs: 3000 });
22
+
23
+ const result = player.tryUse('dash');
24
+ if (result.success) console.log('Dash activated');
25
+ else console.log(`Ready in ${result.remainingMs}ms`);
26
+ ```
27
+
28
+ `tryUse(name)` is the synchronous, non-throwing check-and-consume operation. A successful classic result has the shape `{ success: true, remainingMs, mode: 'classic' }`; a blocked result has `{ success: false, remainingMs, mode: 'classic' }`.
29
+
30
+ ## When to use this
31
+
32
+ Use coolneur when an application needs local cooldown state, such as a game ability, command cooldown, temporary action lockout, or per-entity timer. A stream is a named namespace: create one stream per user, guild, channel, player, game session, or composite key. For example, ``coolneur.stream(`user:${userId}`)`` or ``coolneur.stream(`guild:${guildId}:user:${userId}`)``. Stream names are application-defined; coolneur does not parse Discord or other platform context objects.
33
+
34
+ Choose **classic mode** for one timer per action. Choose **charges mode** when an ability has multiple uses that regenerate one at a time. Use `sharedGroup` when multiple charge-based definitions inside the same stream should draw from one pool.
35
+
36
+ ## Features
37
+
38
+ - Classic cooldowns with configurable durations and optional per-use duration overrides.
39
+ - Multi-charge abilities with sequential charge regeneration.
40
+ - Named streams for isolating state by user, guild, channel, player, session, or composite application key.
41
+ - Shared charge pools within one stream through `sharedGroup`.
42
+ - Synchronous `tryUse()` plus throwing `use()` and non-consuming `start()`.
43
+ - Inspect readiness, remaining time, charges, next recharge, and time until full recharge.
44
+ - Pause, resume, reset, and delete cooldown definitions.
45
+ - Lifecycle events with unsubscribe functions.
46
+ - JSON-serializable snapshots and restore with elapsed-time handling.
47
+ - Injected clock for deterministic tests; no background timer handles are created.
48
+ - JavaScript implementation with TypeScript declaration files and no runtime dependencies.
49
+
50
+ ## API at a glance
51
+
52
+ ### Manager
53
+
54
+ | API | Purpose |
55
+ | --- | --- |
56
+ | `new Coolneur({ clock? })` | Create a manager. `clock` is a `() => number`, defaulting to `Date.now`. |
57
+ | `stream(name)` | Get or create a named `CoolneurStream`. |
58
+ | `on(event, listener)` | Subscribe to an event; returns an unsubscribe function. Use `'*'` to receive all events. |
59
+ | `off(event, listener)` | Remove an event listener. |
60
+ | `snapshot()` | Return a JSON-serializable manager snapshot. |
61
+ | `restore(data, options?)` | Restore streams from a snapshot. |
62
+ | `clear()` | Clear all streams and shared pools. |
63
+
64
+ ### Stream
65
+
66
+ | API | Purpose |
67
+ | --- | --- |
68
+ | `define(name, config?)` | Define a classic or charge-based cooldown. |
69
+ | `has(name)` / `list()` | Check or list definitions. |
70
+ | `start(name, durationMs?)` | Start/restart a classic timer without consuming it. For a charge definition, this delegates to `use()`. |
71
+ | `tryUse(name)` | Atomically check and consume within this synchronous manager call; returns a result object. |
72
+ | `use(name)` | Consume when ready; throws when unavailable. |
73
+ | `remaining(name)` / `isReady(name)` | Inspect remaining time or readiness. |
74
+ | `charges(name)` | Read available charges for a charge-based definition. |
75
+ | `timeUntilNextCharge(name)` | Milliseconds until the next charge regenerates (0 when no charge is waiting to regenerate). |
76
+ | `timeUntilFull(name)` | Milliseconds until the charge pool is full. |
77
+ | `pause(name)` / `resume(name)` | Pause or resume a timer or charge pool. |
78
+ | `reset(name, options?)` | Reset a classic cooldown or charge count. |
79
+ | `delete(name)` | Delete a definition. |
80
+ | `snapshot()` / `restore(data, options?)` | Save or restore one stream. |
81
+
82
+ ## Classic cooldowns
83
+
84
+ ```js
85
+ const player = coolneur.stream('player:42');
86
+ player.define('dash', { durationMs: 3000 });
87
+
88
+ const first = player.tryUse('dash');
89
+ // { success: true, remainingMs: 3000, mode: 'classic' }
90
+
91
+ const second = player.tryUse('dash');
92
+ // { success: false, remainingMs: 3000, mode: 'classic' }
93
+
94
+ player.start('dash', 1500); // start/restart without consuming
95
+ player.pause('dash');
96
+ player.resume('dash');
97
+ ```
98
+
99
+ Classic configuration accepts `durationMs`, with `cooldownMs` and `cooldown` as aliases. `start(name, durationMs)` can override the configured duration for that start.
100
+
101
+ ## Multi-charge cooldowns
102
+
103
+ ```js
104
+ const player = coolneur.stream('player:42');
105
+ player.define('fireball', {
106
+ mode: 'charges',
107
+ maxCharges: 3,
108
+ initialCharges: 3,
109
+ rechargeMs: 4000
110
+ });
111
+
112
+ const result = player.tryUse('fireball');
113
+ // { success: true, charges: 2, remainingMs: 4000, mode: 'charges' }
114
+
115
+ console.log(player.charges('fireball')); // 2
116
+ console.log(player.timeUntilNextCharge('fireball')); // milliseconds until the next recharge
117
+ console.log(player.timeUntilFull('fireball')); // time until all 3 charges are restored
118
+ ```
119
+
120
+ Charges regenerate one at a time. If all three charges are spent at the same moment, the first returns after one `rechargeMs` interval, the second after two intervals, and the third after three. `sharedGroup` makes compatible charge definitions in the same stream use the same pool; the `maxCharges` and `rechargeMs` values must match.
121
+
122
+ ## Scoping examples
123
+
124
+ Coolneur does not impose a platform-specific context model. Use stable application-defined stream names to isolate state:
125
+
126
+ ```js
127
+ const byUser = coolneur.stream(`user:${userId}`);
128
+ const byGuild = coolneur.stream(`guild:${guildId}`);
129
+ const byChannel = coolneur.stream(`channel:${channelId}`);
130
+ const byGuildAndUser = coolneur.stream(`guild:${guildId}:user:${userId}`);
131
+
132
+ byUser.define('daily-reward', { durationMs: 24 * 60 * 60 * 1000 });
133
+ ```
134
+
135
+ Choose a stream key that matches the scope your application needs. A stream is in-memory and belongs to one `Coolneur` manager instance.
136
+
137
+ ## Shared charge pools
138
+
139
+ ```js
140
+ const party = coolneur.stream('party:alpha');
141
+ party.define('shield', {
142
+ mode: 'charges', maxCharges: 2, rechargeMs: 8000, sharedGroup: 'team-energy'
143
+ });
144
+ party.define('heal', {
145
+ mode: 'charges', maxCharges: 2, rechargeMs: 8000, sharedGroup: 'team-energy'
146
+ });
147
+
148
+ party.use('shield');
149
+ console.log(party.charges('heal')); // 1: both definitions read the same pool
150
+ ```
151
+
152
+ Shared groups are local to their stream and manager instance. They are not distributed across processes or servers.
153
+
154
+ ## Events
155
+
156
+ ```js
157
+ const unsubscribe = coolneur.on('use', event => {
158
+ console.log(event.stream, event.cooldown, event.mode);
159
+ });
160
+
161
+ coolneur.on('*', event => console.log(event.event, event));
162
+ unsubscribe();
163
+ ```
164
+
165
+ Events emitted by the current API: `define`, `start`, `use`, `charge`, `pause`, `resume`, `reset`, and `delete`.
166
+
167
+ ## Save and restore
168
+
169
+ ```js
170
+ const saved = coolneur.snapshot();
171
+ localStorage.setItem('coolneur-state', JSON.stringify(saved));
172
+
173
+ const restored = new Coolneur();
174
+ restored.restore(JSON.parse(localStorage.getItem('coolneur-state')), {
175
+ elapsedMs: 2500
176
+ });
177
+ ```
178
+
179
+ `elapsedMs` is the amount of time to account for between saving and restoring. If omitted by `Coolneur.restore()`, elapsed time is derived from `savedAt` and the new manager's clock. Pass `{ applyElapsed: false }` to ignore offline elapsed time when restoring a manager snapshot. Stream-level `restore()` accepts `elapsedMs`.
180
+
181
+ ## Deterministic testing
182
+
183
+ ```js
184
+ let now = 0;
185
+ const testManager = new Coolneur({ clock: () => now });
186
+ const testPlayer = testManager.stream('test-player');
187
+ testPlayer.define('dash', { durationMs: 1000 });
188
+
189
+ testPlayer.use('dash');
190
+ now += 1000;
191
+ console.log(testPlayer.isReady('dash')); // true
192
+ ```
193
+
194
+ The manager reads the injected clock when methods are called. It does not create native timeout or interval handles.
195
+
196
+ ## Configuration reference
197
+
198
+ | Option | Applies to | Type | Default / requirement |
199
+ | --- | --- | --- | --- |
200
+ | `clock` | `Coolneur` constructor | `() => number` | `Date.now` |
201
+ | `durationMs` | classic definition | number, milliseconds | `0` |
202
+ | `cooldownMs`, `cooldown` | classic definition | number, milliseconds | Aliases for `durationMs` |
203
+ | `maxCharges` | charge definition | integer | Required; at least `1` |
204
+ | `rechargeMs` | charge definition | number, milliseconds | Required; at least `0` |
205
+ | `initialCharges` | charge definition | integer | Defaults to `maxCharges` |
206
+ | `sharedGroup` | charge definition | string | Optional; shared within the same stream |
207
+ | `metadata` | either definition | any value | Optional user metadata |
208
+ | `elapsedMs` | restore options | number, milliseconds | `0` for stream restore; manager restore derives it from `savedAt` when omitted |
209
+ | `applyElapsed` | manager restore | boolean | `true` |
210
+
211
+ ## Runnable examples
212
+
213
+ The `examples/` directory contains runnable JavaScript examples:
214
+
215
+ - `basic.mjs` — define and consume a classic cooldown.
216
+ - `charges.mjs` — use multi-charge abilities and inspect recharge timing.
217
+ - `scopes.mjs` — isolate cooldowns with per-user and composite stream names.
218
+ - `persistence.mjs` — serialize and restore a snapshot.
219
+
220
+ Run an example from the package root with `node examples/basic.mjs` (replace the filename as needed).
221
+
222
+ ## What coolneur does not do
223
+
224
+ - It is not a security-grade or distributed rate limiter. For API protection, enforce limits on a trusted server and use shared server-side state where required.
225
+ - It does not provide cross-process locks, a database adapter, or automatic persistence. Applications decide where snapshots are stored and synchronized.
226
+ - It does not parse Discord, guild, channel, or user objects. Those are represented by application-defined stream names.
227
+ - It does not expose `check()`, `peek()`, `consume()`, `scope()`, `addBypass()`, or `checkAsync()` in version 3.0.0. Use the documented API above (`tryUse()`, `remaining()`, `stream()`, and `use()`).
228
+
229
+ ## License
230
+
231
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,161 @@
1
+ const nowDefault = () => Date.now();
2
+
3
+ function assertName(name, label = 'name') {
4
+ if (typeof name !== 'string' || !name.trim()) throw new TypeError(`${label} must be a non-empty string`);
5
+ }
6
+ function assertMs(value, label) {
7
+ if (!Number.isFinite(value) || value < 0) throw new TypeError(`${label} must be a finite number >= 0`);
8
+ }
9
+
10
+ /** Coolneur: timerless, clock-injectable cooldown and charge manager. */
11
+ class Coolneur {
12
+ constructor(options = {}) {
13
+ this.clock = options.clock ?? nowDefault;
14
+ if (typeof this.clock !== 'function') throw new TypeError('clock must be a function');
15
+ this.streams = new Map();
16
+ this.listeners = new Map();
17
+ this.sharedPools = new Map();
18
+ }
19
+ stream(name) {
20
+ assertName(name, 'stream name');
21
+ if (!this.streams.has(name)) this.streams.set(name, new CoolneurStream(this, name));
22
+ return this.streams.get(name);
23
+ }
24
+ on(event, listener) {
25
+ assertName(event, 'event');
26
+ if (typeof listener !== 'function') throw new TypeError('listener must be a function');
27
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
28
+ this.listeners.get(event).add(listener);
29
+ return () => this.off(event, listener);
30
+ }
31
+ off(event, listener) { this.listeners.get(event)?.delete(listener); }
32
+ emit(event, payload) {
33
+ for (const listener of this.listeners.get(event) ?? []) listener(payload);
34
+ for (const listener of this.listeners.get('*') ?? []) listener({ event, ...payload });
35
+ }
36
+ snapshot() {
37
+ return { version: 1, savedAt: this.clock(), streams: [...this.streams.values()].map(s => s.snapshot()) };
38
+ }
39
+ restore(data, options = {}) {
40
+ if (!data || data.version !== 1 || !Array.isArray(data.streams)) throw new TypeError('Invalid Coolneur snapshot');
41
+ const elapsed = options.elapsedMs ?? Math.max(0, this.clock() - (data.savedAt ?? this.clock()));
42
+ assertMs(elapsed, 'elapsedMs');
43
+ for (const streamData of data.streams) this.stream(streamData.name).restore(streamData, { elapsedMs: options.applyElapsed === false ? 0 : elapsed });
44
+ return this;
45
+ }
46
+ clear() { this.streams.clear(); this.sharedPools.clear(); }
47
+ }
48
+
49
+ class CoolneurStream {
50
+ constructor(manager, name) { this.manager = manager; this.name = name; this.entries = new Map(); }
51
+ define(name, config = {}) {
52
+ assertName(name, 'cooldown name');
53
+ if (this.entries.has(name)) throw new Error(`Cooldown "${name}" is already defined in stream "${this.name}"`);
54
+ const mode = config.mode ?? (config.maxCharges !== undefined ? 'charges' : 'classic');
55
+ if (mode !== 'classic' && mode !== 'charges') throw new TypeError('mode must be "classic" or "charges"');
56
+ const duration = config.durationMs ?? config.cooldownMs ?? config.cooldown ?? 0;
57
+ assertMs(duration, 'durationMs');
58
+ let entry;
59
+ if (mode === 'classic') {
60
+ entry = { name, mode, durationMs: duration, readyAt: this.manager.clock(), paused: false, pausedRemaining: 0, metadata: config.metadata };
61
+ } else {
62
+ const maxCharges = config.maxCharges;
63
+ const rechargeMs = config.rechargeMs;
64
+ if (!Number.isInteger(maxCharges) || maxCharges < 1) throw new TypeError('maxCharges must be an integer >= 1');
65
+ assertMs(rechargeMs, 'rechargeMs');
66
+ const initialCharges = config.initialCharges ?? maxCharges;
67
+ if (!Number.isInteger(initialCharges) || initialCharges < 0 || initialCharges > maxCharges) throw new TypeError('initialCharges must be an integer from 0 to maxCharges');
68
+ const sharedKey = config.sharedGroup;
69
+ let pool;
70
+ if (sharedKey != null) {
71
+ assertName(sharedKey, 'sharedGroup');
72
+ const poolKey = `${this.name}:${sharedKey}`;
73
+ pool = this.manager.sharedPools.get(poolKey);
74
+ if (pool && (pool.maxCharges !== maxCharges || pool.rechargeMs !== rechargeMs)) throw new Error(`Shared group "${sharedKey}" has incompatible charge settings`);
75
+ if (!pool) { pool = { maxCharges, rechargeMs, charges: initialCharges, queue: [], paused: false, pausedAt: 0, key: poolKey }; this.manager.sharedPools.set(poolKey, pool); }
76
+ } else pool = { maxCharges, rechargeMs, charges: initialCharges, queue: [], paused: false, pausedAt: 0 };
77
+ entry = { name, mode, pool, sharedGroup: sharedKey, metadata: config.metadata };
78
+ }
79
+ this.entries.set(name, entry);
80
+ this.manager.emit('define', { stream: this.name, cooldown: name, mode });
81
+ return this;
82
+ }
83
+ has(name) { return this.entries.has(name); }
84
+ get(name) { const e = this.entries.get(name); if (!e) throw new Error(`Unknown cooldown "${name}" in stream "${this.name}"`); return e; }
85
+ start(name, durationMs) {
86
+ const e = this.get(name); if (e.mode !== 'classic') return this.use(name);
87
+ const duration = durationMs ?? e.durationMs; assertMs(duration, 'durationMs');
88
+ e.readyAt = this.manager.clock() + duration; e.paused = false;
89
+ this.manager.emit('start', { stream: this.name, cooldown: name, durationMs: duration, readyAt: e.readyAt });
90
+ return this;
91
+ }
92
+ use(name) { const result = this.tryUse(name); if (!result.success) throw new Error(`Cooldown "${name}" is not ready (${result.remainingMs}ms remaining)`); return this; }
93
+ tryUse(name) {
94
+ const e = this.get(name), now = this.manager.clock();
95
+ if (e.mode === 'classic') {
96
+ const remainingMs = this.remaining(name);
97
+ if (remainingMs > 0) return { success: false, remainingMs, mode: 'classic' };
98
+ e.readyAt = now + e.durationMs;
99
+ this.manager.emit('use', { stream: this.name, cooldown: name, mode: 'classic', readyAt: e.readyAt });
100
+ return { success: true, remainingMs: e.durationMs, mode: 'classic' };
101
+ }
102
+ const p = e.pool; this._syncPool(p, now);
103
+ if (p.paused || p.charges <= 0) return { success: false, remainingMs: this.timeUntilNextCharge(name), mode: 'charges', charges: p.charges };
104
+ p.charges -= 1;
105
+ if (p.charges < p.maxCharges && p.queue.length === 0) p.queue.push(now + p.rechargeMs);
106
+ else if (p.charges < p.maxCharges) p.queue.push(p.queue[p.queue.length - 1] + p.rechargeMs);
107
+ this.manager.emit('use', { stream: this.name, cooldown: name, mode: 'charges', charges: p.charges });
108
+ this.manager.emit('charge', { stream: this.name, cooldown: name, charges: p.charges, maxCharges: p.maxCharges });
109
+ return { success: true, charges: p.charges, remainingMs: this.timeUntilNextCharge(name), mode: 'charges' };
110
+ }
111
+ remaining(name) { const e = this.get(name); if (e.mode === 'charges') return this.timeUntilNextCharge(name); if (e.paused) return e.pausedRemaining; return Math.max(0, e.readyAt - this.manager.clock()); }
112
+ isReady(name) { const e = this.get(name); return e.mode === 'charges' ? this.charges(name) > 0 && !e.pool.paused : this.remaining(name) === 0; }
113
+ charges(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); this._syncPool(e.pool, this.manager.clock()); return e.pool.charges; }
114
+ timeUntilNextCharge(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); const p = e.pool; this._syncPool(p, this.manager.clock()); return p.paused ? (p.pausedRemaining ?? 0) : Math.max(0, (p.queue[0] ?? this.manager.clock()) - this.manager.clock()); }
115
+ timeUntilFull(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); const p = e.pool; this._syncPool(p, this.manager.clock()); return p.paused ? (p.pausedRemaining ?? 0) + Math.max(0, p.maxCharges - p.charges - 1) * p.rechargeMs : p.queue.length ? Math.max(0, p.queue[p.queue.length - 1] - this.manager.clock()) : 0; }
116
+ _syncPool(p, now) {
117
+ if (p.paused) return;
118
+ while (p.queue.length && p.queue[0] <= now) { p.queue.shift(); p.charges = Math.min(p.maxCharges, p.charges + 1); }
119
+ if (p.charges >= p.maxCharges) p.queue.length = 0;
120
+ }
121
+ pause(name) {
122
+ const e = this.get(name), now = this.manager.clock();
123
+ if (e.mode === 'classic') { if (!e.paused) { e.pausedRemaining = Math.max(0, e.readyAt - now); e.paused = true; } }
124
+ else { const p = e.pool; this._syncPool(p, now); if (!p.paused) { p.pausedRemaining = p.queue.length ? Math.max(0, p.queue[0] - now) : 0; p.pausedAt = now; p.paused = true; } }
125
+ this.manager.emit('pause', { stream: this.name, cooldown: name }); return this;
126
+ }
127
+ resume(name) {
128
+ const e = this.get(name), now = this.manager.clock();
129
+ if (e.mode === 'classic') { if (e.paused) { e.readyAt = now + e.pausedRemaining; e.paused = false; } }
130
+ else { const p = e.pool; if (p.paused) { p.paused = false; if (p.charges < p.maxCharges) { p.queue = [now + (p.pausedRemaining ?? p.rechargeMs)]; for (let i = 1; i < p.maxCharges - p.charges; i++) p.queue.push(p.queue[i - 1] + p.rechargeMs); } p.pausedRemaining = 0; } }
131
+ this.manager.emit('resume', { stream: this.name, cooldown: name }); return this;
132
+ }
133
+ reset(name, options = {}) {
134
+ const e = this.get(name);
135
+ if (e.mode === 'classic') { e.readyAt = this.manager.clock(); e.paused = false; e.pausedRemaining = 0; }
136
+ else { const p = e.pool; p.charges = options.charges ?? p.maxCharges; if (!Number.isInteger(p.charges) || p.charges < 0 || p.charges > p.maxCharges) throw new TypeError('charges must be from 0 to maxCharges'); p.queue = []; p.paused = false; p.pausedRemaining = 0; }
137
+ this.manager.emit('reset', { stream: this.name, cooldown: name }); return this;
138
+ }
139
+ delete(name) { const deleted = this.entries.delete(name); if (deleted) this.manager.emit('delete', { stream: this.name, cooldown: name }); return deleted; }
140
+ list() { return [...this.entries.keys()]; }
141
+ snapshot() {
142
+ const now = this.manager.clock();
143
+ return { name: this.name, cooldowns: [...this.entries.values()].map(e => e.mode === 'classic' ? { name: e.name, mode: e.mode, durationMs: e.durationMs, remainingMs: this.remaining(e.name), paused: e.paused, metadata: e.metadata } : (this._syncPool(e.pool, now), { name: e.name, mode: e.mode, maxCharges: e.pool.maxCharges, rechargeMs: e.pool.rechargeMs, charges: e.pool.charges, queueRemainingMs: e.pool.queue.map(t => Math.max(0, t - now)), paused: e.pool.paused, pausedRemaining: e.pool.pausedRemaining, sharedGroup: e.sharedGroup, metadata: e.metadata })) };
144
+ }
145
+ restore(data, options = {}) {
146
+ if (!data || data.name !== this.name || !Array.isArray(data.cooldowns)) throw new TypeError('Invalid stream snapshot');
147
+ const elapsed = options.elapsedMs ?? 0; assertMs(elapsed, 'elapsedMs');
148
+ for (const item of data.cooldowns) {
149
+ if (!this.entries.has(item.name)) {
150
+ if (item.mode === 'classic') this.define(item.name, { durationMs: item.durationMs, metadata: item.metadata });
151
+ else this.define(item.name, { mode: 'charges', maxCharges: item.maxCharges, rechargeMs: item.rechargeMs, initialCharges: item.charges, sharedGroup: item.sharedGroup, metadata: item.metadata });
152
+ }
153
+ const e = this.get(item.name), now = this.manager.clock();
154
+ if (e.mode === 'classic' && item.mode === 'classic') { e.paused = !!item.paused; e.pausedRemaining = item.remainingMs; e.readyAt = now + Math.max(0, item.remainingMs - elapsed); if (!e.paused) e.readyAt = now + Math.max(0, item.remainingMs - elapsed); }
155
+ else if (e.mode === 'charges' && item.mode === 'charges') { const p = e.pool; p.charges = item.charges; p.paused = !!item.paused; p.pausedRemaining = item.pausedRemaining; p.queue = item.queueRemainingMs.map(ms => now + Math.max(0, ms - elapsed)); this._syncPool(p, now); }
156
+ }
157
+ return this;
158
+ }
159
+ }
160
+
161
+ module.exports = { Coolneur, CoolneurStream, default: Coolneur };
@@ -0,0 +1,37 @@
1
+ export type CooldownMode = 'classic' | 'charges';
2
+ export interface ClassicConfig { mode?: 'classic'; durationMs?: number; cooldownMs?: number; cooldown?: number; metadata?: unknown }
3
+ export interface ChargesConfig { mode?: 'charges'; maxCharges: number; rechargeMs: number; initialCharges?: number; sharedGroup?: string; metadata?: unknown }
4
+ export type CooldownConfig = ClassicConfig | ChargesConfig;
5
+ export interface TryUseResult { success: boolean; remainingMs: number; mode: CooldownMode; charges?: number }
6
+ export interface CoolneurOptions { clock?: () => number }
7
+ export interface RestoreOptions { elapsedMs?: number; applyElapsed?: boolean }
8
+ export declare class Coolneur {
9
+ constructor(options?: CoolneurOptions);
10
+ stream(name: string): CoolneurStream;
11
+ on(event: string, listener: (payload: any) => void): () => void;
12
+ off(event: string, listener: (payload: any) => void): void;
13
+ snapshot(): { version: 1; savedAt: number; streams: any[] };
14
+ restore(data: any, options?: RestoreOptions): this;
15
+ clear(): void;
16
+ }
17
+ export declare class CoolneurStream {
18
+ readonly name: string;
19
+ define(name: string, config?: CooldownConfig): this;
20
+ has(name: string): boolean;
21
+ start(name: string, durationMs?: number): this;
22
+ use(name: string): this;
23
+ tryUse(name: string): TryUseResult;
24
+ remaining(name: string): number;
25
+ isReady(name: string): boolean;
26
+ charges(name: string): number;
27
+ timeUntilNextCharge(name: string): number;
28
+ timeUntilFull(name: string): number;
29
+ pause(name: string): this;
30
+ resume(name: string): this;
31
+ reset(name: string, options?: { charges?: number }): this;
32
+ delete(name: string): boolean;
33
+ list(): string[];
34
+ snapshot(): any;
35
+ restore(data: any, options?: RestoreOptions): this;
36
+ }
37
+ export default Coolneur;
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ const nowDefault = () => Date.now();
2
+
3
+ function assertName(name, label = 'name') {
4
+ if (typeof name !== 'string' || !name.trim()) throw new TypeError(`${label} must be a non-empty string`);
5
+ }
6
+ function assertMs(value, label) {
7
+ if (!Number.isFinite(value) || value < 0) throw new TypeError(`${label} must be a finite number >= 0`);
8
+ }
9
+
10
+ /** Coolneur: timerless, clock-injectable cooldown and charge manager. */
11
+ export class Coolneur {
12
+ constructor(options = {}) {
13
+ this.clock = options.clock ?? nowDefault;
14
+ if (typeof this.clock !== 'function') throw new TypeError('clock must be a function');
15
+ this.streams = new Map();
16
+ this.listeners = new Map();
17
+ this.sharedPools = new Map();
18
+ }
19
+ stream(name) {
20
+ assertName(name, 'stream name');
21
+ if (!this.streams.has(name)) this.streams.set(name, new CoolneurStream(this, name));
22
+ return this.streams.get(name);
23
+ }
24
+ on(event, listener) {
25
+ assertName(event, 'event');
26
+ if (typeof listener !== 'function') throw new TypeError('listener must be a function');
27
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
28
+ this.listeners.get(event).add(listener);
29
+ return () => this.off(event, listener);
30
+ }
31
+ off(event, listener) { this.listeners.get(event)?.delete(listener); }
32
+ emit(event, payload) {
33
+ for (const listener of this.listeners.get(event) ?? []) listener(payload);
34
+ for (const listener of this.listeners.get('*') ?? []) listener({ event, ...payload });
35
+ }
36
+ snapshot() {
37
+ return { version: 1, savedAt: this.clock(), streams: [...this.streams.values()].map(s => s.snapshot()) };
38
+ }
39
+ restore(data, options = {}) {
40
+ if (!data || data.version !== 1 || !Array.isArray(data.streams)) throw new TypeError('Invalid Coolneur snapshot');
41
+ const elapsed = options.elapsedMs ?? Math.max(0, this.clock() - (data.savedAt ?? this.clock()));
42
+ assertMs(elapsed, 'elapsedMs');
43
+ for (const streamData of data.streams) this.stream(streamData.name).restore(streamData, { elapsedMs: options.applyElapsed === false ? 0 : elapsed });
44
+ return this;
45
+ }
46
+ clear() { this.streams.clear(); this.sharedPools.clear(); }
47
+ }
48
+
49
+ export class CoolneurStream {
50
+ constructor(manager, name) { this.manager = manager; this.name = name; this.entries = new Map(); }
51
+ define(name, config = {}) {
52
+ assertName(name, 'cooldown name');
53
+ if (this.entries.has(name)) throw new Error(`Cooldown "${name}" is already defined in stream "${this.name}"`);
54
+ const mode = config.mode ?? (config.maxCharges !== undefined ? 'charges' : 'classic');
55
+ if (mode !== 'classic' && mode !== 'charges') throw new TypeError('mode must be "classic" or "charges"');
56
+ const duration = config.durationMs ?? config.cooldownMs ?? config.cooldown ?? 0;
57
+ assertMs(duration, 'durationMs');
58
+ let entry;
59
+ if (mode === 'classic') {
60
+ entry = { name, mode, durationMs: duration, readyAt: this.manager.clock(), paused: false, pausedRemaining: 0, metadata: config.metadata };
61
+ } else {
62
+ const maxCharges = config.maxCharges;
63
+ const rechargeMs = config.rechargeMs;
64
+ if (!Number.isInteger(maxCharges) || maxCharges < 1) throw new TypeError('maxCharges must be an integer >= 1');
65
+ assertMs(rechargeMs, 'rechargeMs');
66
+ const initialCharges = config.initialCharges ?? maxCharges;
67
+ if (!Number.isInteger(initialCharges) || initialCharges < 0 || initialCharges > maxCharges) throw new TypeError('initialCharges must be an integer from 0 to maxCharges');
68
+ const sharedKey = config.sharedGroup;
69
+ let pool;
70
+ if (sharedKey != null) {
71
+ assertName(sharedKey, 'sharedGroup');
72
+ const poolKey = `${this.name}:${sharedKey}`;
73
+ pool = this.manager.sharedPools.get(poolKey);
74
+ if (pool && (pool.maxCharges !== maxCharges || pool.rechargeMs !== rechargeMs)) throw new Error(`Shared group "${sharedKey}" has incompatible charge settings`);
75
+ if (!pool) { pool = { maxCharges, rechargeMs, charges: initialCharges, queue: [], paused: false, pausedAt: 0, key: poolKey }; this.manager.sharedPools.set(poolKey, pool); }
76
+ } else pool = { maxCharges, rechargeMs, charges: initialCharges, queue: [], paused: false, pausedAt: 0 };
77
+ entry = { name, mode, pool, sharedGroup: sharedKey, metadata: config.metadata };
78
+ }
79
+ this.entries.set(name, entry);
80
+ this.manager.emit('define', { stream: this.name, cooldown: name, mode });
81
+ return this;
82
+ }
83
+ has(name) { return this.entries.has(name); }
84
+ get(name) { const e = this.entries.get(name); if (!e) throw new Error(`Unknown cooldown "${name}" in stream "${this.name}"`); return e; }
85
+ start(name, durationMs) {
86
+ const e = this.get(name); if (e.mode !== 'classic') return this.use(name);
87
+ const duration = durationMs ?? e.durationMs; assertMs(duration, 'durationMs');
88
+ e.readyAt = this.manager.clock() + duration; e.paused = false;
89
+ this.manager.emit('start', { stream: this.name, cooldown: name, durationMs: duration, readyAt: e.readyAt });
90
+ return this;
91
+ }
92
+ use(name) { const result = this.tryUse(name); if (!result.success) throw new Error(`Cooldown "${name}" is not ready (${result.remainingMs}ms remaining)`); return this; }
93
+ tryUse(name) {
94
+ const e = this.get(name), now = this.manager.clock();
95
+ if (e.mode === 'classic') {
96
+ const remainingMs = this.remaining(name);
97
+ if (remainingMs > 0) return { success: false, remainingMs, mode: 'classic' };
98
+ e.readyAt = now + e.durationMs;
99
+ this.manager.emit('use', { stream: this.name, cooldown: name, mode: 'classic', readyAt: e.readyAt });
100
+ return { success: true, remainingMs: e.durationMs, mode: 'classic' };
101
+ }
102
+ const p = e.pool; this._syncPool(p, now);
103
+ if (p.paused || p.charges <= 0) return { success: false, remainingMs: this.timeUntilNextCharge(name), mode: 'charges', charges: p.charges };
104
+ p.charges -= 1;
105
+ if (p.charges < p.maxCharges && p.queue.length === 0) p.queue.push(now + p.rechargeMs);
106
+ else if (p.charges < p.maxCharges) p.queue.push(p.queue[p.queue.length - 1] + p.rechargeMs);
107
+ this.manager.emit('use', { stream: this.name, cooldown: name, mode: 'charges', charges: p.charges });
108
+ this.manager.emit('charge', { stream: this.name, cooldown: name, charges: p.charges, maxCharges: p.maxCharges });
109
+ return { success: true, charges: p.charges, remainingMs: this.timeUntilNextCharge(name), mode: 'charges' };
110
+ }
111
+ remaining(name) { const e = this.get(name); if (e.mode === 'charges') return this.timeUntilNextCharge(name); if (e.paused) return e.pausedRemaining; return Math.max(0, e.readyAt - this.manager.clock()); }
112
+ isReady(name) { const e = this.get(name); return e.mode === 'charges' ? this.charges(name) > 0 && !e.pool.paused : this.remaining(name) === 0; }
113
+ charges(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); this._syncPool(e.pool, this.manager.clock()); return e.pool.charges; }
114
+ timeUntilNextCharge(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); const p = e.pool; this._syncPool(p, this.manager.clock()); return p.paused ? (p.pausedRemaining ?? 0) : Math.max(0, (p.queue[0] ?? this.manager.clock()) - this.manager.clock()); }
115
+ timeUntilFull(name) { const e = this.get(name); if (e.mode !== 'charges') throw new Error(`Cooldown "${name}" is not charge-based`); const p = e.pool; this._syncPool(p, this.manager.clock()); return p.paused ? (p.pausedRemaining ?? 0) + Math.max(0, p.maxCharges - p.charges - 1) * p.rechargeMs : p.queue.length ? Math.max(0, p.queue[p.queue.length - 1] - this.manager.clock()) : 0; }
116
+ _syncPool(p, now) {
117
+ if (p.paused) return;
118
+ while (p.queue.length && p.queue[0] <= now) { p.queue.shift(); p.charges = Math.min(p.maxCharges, p.charges + 1); }
119
+ if (p.charges >= p.maxCharges) p.queue.length = 0;
120
+ }
121
+ pause(name) {
122
+ const e = this.get(name), now = this.manager.clock();
123
+ if (e.mode === 'classic') { if (!e.paused) { e.pausedRemaining = Math.max(0, e.readyAt - now); e.paused = true; } }
124
+ else { const p = e.pool; this._syncPool(p, now); if (!p.paused) { p.pausedRemaining = p.queue.length ? Math.max(0, p.queue[0] - now) : 0; p.pausedAt = now; p.paused = true; } }
125
+ this.manager.emit('pause', { stream: this.name, cooldown: name }); return this;
126
+ }
127
+ resume(name) {
128
+ const e = this.get(name), now = this.manager.clock();
129
+ if (e.mode === 'classic') { if (e.paused) { e.readyAt = now + e.pausedRemaining; e.paused = false; } }
130
+ else { const p = e.pool; if (p.paused) { p.paused = false; if (p.charges < p.maxCharges) { p.queue = [now + (p.pausedRemaining ?? p.rechargeMs)]; for (let i = 1; i < p.maxCharges - p.charges; i++) p.queue.push(p.queue[i - 1] + p.rechargeMs); } p.pausedRemaining = 0; } }
131
+ this.manager.emit('resume', { stream: this.name, cooldown: name }); return this;
132
+ }
133
+ reset(name, options = {}) {
134
+ const e = this.get(name);
135
+ if (e.mode === 'classic') { e.readyAt = this.manager.clock(); e.paused = false; e.pausedRemaining = 0; }
136
+ else { const p = e.pool; p.charges = options.charges ?? p.maxCharges; if (!Number.isInteger(p.charges) || p.charges < 0 || p.charges > p.maxCharges) throw new TypeError('charges must be from 0 to maxCharges'); p.queue = []; p.paused = false; p.pausedRemaining = 0; }
137
+ this.manager.emit('reset', { stream: this.name, cooldown: name }); return this;
138
+ }
139
+ delete(name) { const deleted = this.entries.delete(name); if (deleted) this.manager.emit('delete', { stream: this.name, cooldown: name }); return deleted; }
140
+ list() { return [...this.entries.keys()]; }
141
+ snapshot() {
142
+ const now = this.manager.clock();
143
+ return { name: this.name, cooldowns: [...this.entries.values()].map(e => e.mode === 'classic' ? { name: e.name, mode: e.mode, durationMs: e.durationMs, remainingMs: this.remaining(e.name), paused: e.paused, metadata: e.metadata } : (this._syncPool(e.pool, now), { name: e.name, mode: e.mode, maxCharges: e.pool.maxCharges, rechargeMs: e.pool.rechargeMs, charges: e.pool.charges, queueRemainingMs: e.pool.queue.map(t => Math.max(0, t - now)), paused: e.pool.paused, pausedRemaining: e.pool.pausedRemaining, sharedGroup: e.sharedGroup, metadata: e.metadata })) };
144
+ }
145
+ restore(data, options = {}) {
146
+ if (!data || data.name !== this.name || !Array.isArray(data.cooldowns)) throw new TypeError('Invalid stream snapshot');
147
+ const elapsed = options.elapsedMs ?? 0; assertMs(elapsed, 'elapsedMs');
148
+ for (const item of data.cooldowns) {
149
+ if (!this.entries.has(item.name)) {
150
+ if (item.mode === 'classic') this.define(item.name, { durationMs: item.durationMs, metadata: item.metadata });
151
+ else this.define(item.name, { mode: 'charges', maxCharges: item.maxCharges, rechargeMs: item.rechargeMs, initialCharges: item.charges, sharedGroup: item.sharedGroup, metadata: item.metadata });
152
+ }
153
+ const e = this.get(item.name), now = this.manager.clock();
154
+ if (e.mode === 'classic' && item.mode === 'classic') { e.paused = !!item.paused; e.pausedRemaining = item.remainingMs; e.readyAt = now + Math.max(0, item.remainingMs - elapsed); if (!e.paused) e.readyAt = now + Math.max(0, item.remainingMs - elapsed); }
155
+ else if (e.mode === 'charges' && item.mode === 'charges') { const p = e.pool; p.charges = item.charges; p.paused = !!item.paused; p.pausedRemaining = item.pausedRemaining; p.queue = item.queueRemainingMs.map(ms => now + Math.max(0, ms - elapsed)); this._syncPool(p, now); }
156
+ }
157
+ return this;
158
+ }
159
+ }
160
+
161
+ export default Coolneur;
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "coolneur",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Cooldown and charge manager for JavaScript and TypeScript: classic timers, multi-charge abilities, scoped streams, pause/resume, snapshots, and deterministic clocks. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE",
20
+ "llms.txt",
21
+ "llms-full.txt",
22
+ "examples"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test",
26
+ "check": "node --check dist/index.js && node --check dist/index.cjs",
27
+ "prepack": "npm run check && npm test"
28
+ },
29
+ "keywords": [
30
+ "cooldown",
31
+ "cooldowns",
32
+ "cooldown-manager",
33
+ "cooldown-management",
34
+ "rate-limit",
35
+ "rate-limiting",
36
+ "throttle",
37
+ "timer",
38
+ "charges",
39
+ "multi-charge",
40
+ "charge-system",
41
+ "game-cooldown",
42
+ "ability-cooldown",
43
+ "javascript",
44
+ "typescript",
45
+ "zero-dependency",
46
+ "user-cooldown",
47
+ "guild-cooldown",
48
+ "channel-cooldown",
49
+ "command-cooldown"
50
+ ],
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/jeffrish-benito/coolneur.git"
55
+ },
56
+ "homepage": "https://github.com/jeffrish-benito/coolneur#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/jeffrish-benito/coolneur/issues"
59
+ },
60
+ "engines": {
61
+ "node": ">=18"
62
+ }
63
+ }