zx-kit 0.16.0 → 0.16.1

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.
Files changed (2) hide show
  1. package/README.md +131 -0
  2. package/package.json +5 -1
package/README.md CHANGED
@@ -30,6 +30,7 @@ The goal is simple: **it should look and sound like a Spectrum, but run like a m
30
30
  - **AABB collision resolution** — sprite vs. sprite overlap, sprite vs. tile map wall resolution with directional hit flags
31
31
  - **Keyboard input** — configurable key-repeat, single-consume action flags, instant state reset on phase transitions
32
32
  - **ZX-style UI widgets** — progress bars with managed lifetime, boxes, frames, panel titles
33
+ - **Typed save / load** — persistent saves via `localStorage` with schema versioning, migrations, slot enumeration, in-memory throttling, and discriminated Result types for every failure mode
33
34
  - **Zero dependencies** — only Web platform APIs: `Canvas`, `Web Audio`, `KeyboardEvent`
34
35
  - **Tree-shakeable** — `sideEffects: false`, so unused modules are dropped from your production bundle
35
36
  - **TypeScript-first** — strict mode, full `.d.ts` declarations, no `any`
@@ -542,6 +543,7 @@ requestAnimationFrame(loop)
542
543
  | [`animation.ts`](#animationts--frame-timer--tween) | Frame-timer for sprite strips, position tween between two points |
543
544
  | [`camera.ts`](#camerats--scrolling-camera) | Viewport that follows a target with lerp + deadzone, world-bounds clamping |
544
545
  | [`scene.ts`](#scenets--scene-manager) | Stack-based scene manager with onEnter/onExit/onPause/onResume hooks |
546
+ | [`save.ts`](#savets--typed-save--load) | Typed save/load via callbacks, versioning + migrations, slot enumeration, throttling, Result types |
545
547
  | [`tilemap.ts`](#tilemapts--tile-map-engine) | Scrollable maps, solid tiles, O(1) id-index, background swap |
546
548
  | [`palette.ts`](#palettets--color-constants) | 15 Spectrum colors, `SpectrumColor` type, `CELL`, `SCALE` |
547
549
  | [`font.ts`](#fontts--rom-bitmap-font) | 96-character ROM font, raw bitmap access |
@@ -1534,6 +1536,135 @@ Renders every scene from bottom to top. No-op on an empty manager.
1534
1536
 
1535
1537
  ---
1536
1538
 
1539
+ ## `save.ts` — Typed Save / Load
1540
+
1541
+ Persistent save / load via `localStorage` with versioning, schema migration, slot enumeration, and in-memory throttling. The game declares its state shape through `serialize` / `deserialize` callbacks; the kit handles storage, namespacing, error mapping, and throttle timing. Every operation returns a discriminated Result type — `quota`, `disabled`, `corrupt`, `version_unsupported` and the rest are distinct, so the game can react to each failure mode if it cares.
1542
+
1543
+ ```ts
1544
+ import {
1545
+ createSaveProfile, writeSave, writeSaveThrottled,
1546
+ readSave, readSaveLatest, saveExists, deleteSave, listSaves,
1547
+ } from 'zx-kit'
1548
+
1549
+ type MyGameSave = {
1550
+ score: number
1551
+ lives: number
1552
+ probed: string[] // Set<string> serialized to array
1553
+ }
1554
+
1555
+ const save = createSaveProfile<MyGameSave>({
1556
+ key: 'my-game',
1557
+ version: 1,
1558
+ serialize: () => ({
1559
+ score: game.score,
1560
+ lives: game.lives,
1561
+ probed: [...game.probedCells],
1562
+ }),
1563
+ deserialize: (data) => {
1564
+ game.score = data.score
1565
+ game.lives = data.lives
1566
+ game.probedCells = new Set(data.probed)
1567
+ },
1568
+ })
1569
+
1570
+ writeSave(save, 'manual') // immediate write to 'manual'
1571
+ writeSaveThrottled(save, 'auto', 5000) // skips if last 'auto' write < 5s ago
1572
+ readSaveLatest(save) // load newest slot, calls deserialize
1573
+ deleteSave(save, 'auto') // remove slot + clear its throttle entry
1574
+ ```
1575
+
1576
+ ### Why callbacks, not a "save the whole state" snapshot
1577
+
1578
+ In emulators a full RAM dump round-trips losslessly because RAM is a byte array. JavaScript state is an object graph: `JSON.stringify(gameState)` silently corrupts `Set`, `Map`, class instances and circular references; a snapshot also persists transient runtime state (audio nodes, `requestAnimationFrame` IDs) that has no business surviving. Forcing the game to declare what's in a save via `serialize` keeps the kit state-agnostic and gives the game a place to convert non-JSON values back and forth.
1579
+
1580
+ ### `SaveProfileConfig<T>` interface
1581
+
1582
+ | Field | Description |
1583
+ |-------|-------------|
1584
+ | `key` | Game key — used as namespace in storage. Unique per game. |
1585
+ | `version` | Current schema version. Increment when the shape of `T` changes. |
1586
+ | `serialize` | Returns the current game state as a JSON-safe `T`. |
1587
+ | `deserialize` | Applies a loaded `T` back to the game (side effect — the game owns the mutation). |
1588
+ | `migrate?` | `(data: unknown, fromVersion: number) => T` — runs when the loaded envelope is older than `version`. If absent and `fromVersion < version`, load fails with `version_unsupported`. |
1589
+
1590
+ ### `SaveResult` / `LoadResult`
1591
+
1592
+ ```ts
1593
+ type SaveResult =
1594
+ | { ok: true }
1595
+ | { ok: false, reason: 'quota' | 'disabled' | 'serialize_error' | 'throttled', error?: Error }
1596
+
1597
+ type LoadResult =
1598
+ | { ok: true, slot: string }
1599
+ | { ok: false, reason: 'not_found' | 'corrupt' | 'version_unsupported' | 'parse_error' | 'disabled', error?: Error }
1600
+ ```
1601
+
1602
+ `throttled` is not a true failure — it means the throttle interval hadn't elapsed and the write was skipped. Surfaced as `ok: false` so callers can distinguish skipping from a real success, but typically ignored.
1603
+
1604
+ ### `createSaveProfile<T>(config): SaveProfile<T>`
1605
+
1606
+ Registers a save profile. Call once at startup and reuse the returned handle for every operation. The handle also carries in-memory throttle state (per-slot last-write timestamps).
1607
+
1608
+ ### `writeSave(profile, slot?): SaveResult`
1609
+
1610
+ Writes immediately. Calls `serialize`, wraps the result as `{ version, timestamp, data }` and stores under `zxkit:<key>:<slot>`. Default slot is `'default'`.
1611
+
1612
+ ### `writeSaveThrottled(profile, slot, minIntervalMs): SaveResult`
1613
+
1614
+ Writes only if at least `minIntervalMs` has elapsed since the last successful write to the same slot in this session. The first call to a given slot always proceeds — the throttle only applies once there's a prior write to compare against. Throttle state lives in memory; a page reload resets it.
1615
+
1616
+ ### `readSave(profile, slot?): LoadResult`
1617
+
1618
+ Reads a slot, runs `migrate` if the stored version is older than the profile version, then calls `deserialize` with the result. On `ok`, the game state has been restored.
1619
+
1620
+ ### `readSaveLatest(profile): LoadResult`
1621
+
1622
+ Enumerates every slot belonging to this profile's key and loads the one with the most recent `timestamp`. Returns `{ ok: false, reason: 'not_found' }` when no slots exist.
1623
+
1624
+ ### `saveExists(profile, slot?): boolean`
1625
+
1626
+ True iff the slot key exists in storage. Does not validate envelope shape — use `readSave` for that.
1627
+
1628
+ ### `deleteSave(profile, slot?): boolean`
1629
+
1630
+ Removes the slot. Also clears the slot's throttle entry, so the next `writeSaveThrottled` to that slot proceeds immediately. Returns `true` if a slot was actually removed.
1631
+
1632
+ ### `listSaves(profile): SlotInfo[]`
1633
+
1634
+ Returns `{ name, timestamp, version, sizeBytes }[]` for every slot belonging to this profile. Corrupt or mis-shaped entries are silently skipped — they will surface as `corrupt` if loaded explicitly via `readSave`.
1635
+
1636
+ ### Versioning and migration
1637
+
1638
+ Every saved payload carries `{ version, timestamp, data }`. On load, when the stored version is older than the profile's current version, `migrate` is called with the raw `data` and the version it was saved at. If the stored version is newer than the profile's, the load fails with `version_unsupported` — a downgrade cannot read forward.
1639
+
1640
+ ```ts
1641
+ createSaveProfile({
1642
+ key: 'my-game',
1643
+ version: 3,
1644
+ migrate: (data, fromVersion) => {
1645
+ let d = data as Record<string, unknown>
1646
+ if (fromVersion < 2) d = { ...d, lives: 3 } // v1 → v2: gained 'lives'
1647
+ if (fromVersion < 3) d = { ...d, probed: [] } // v2 → v3: gained 'probed'
1648
+ return d as MyGameSave
1649
+ },
1650
+ deserialize: (data) => { /* always receives v3 shape */ },
1651
+ })
1652
+ ```
1653
+
1654
+ If `migrate` throws, the read fails with `corrupt`.
1655
+
1656
+ ### Slot naming — convention, not policy
1657
+
1658
+ The kit places no policy on slot names. A useful pattern for retro-style games:
1659
+
1660
+ - `'auto'` — written via `writeSaveThrottled` at meaningful game events (level complete, checkpoint, after a major state change).
1661
+ - `'manual'` — written via `writeSave` when the player hits a save key.
1662
+ - Load via `readSaveLatest` to pick whichever is newer.
1663
+
1664
+ This composes a "your last meaningful checkpoint is always available" UX without a load-slot menu.
1665
+
1666
+ ---
1667
+
1537
1668
  ## `tilemap.ts` — Tile Map Engine
1538
1669
 
1539
1670
  A scrollable, queryable tile map backed by an O(1) id-index. Tiles use the same 8×8 sprite format as `drawSprite`. Supports solid-tile collision queries, viewport-clipped rendering, and smart seasonal background swapping.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zx-kit",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zrebec/zx-kit.git"
@@ -36,11 +36,15 @@
36
36
  "node": ">=22"
37
37
  },
38
38
  "license": "MIT",
39
+ "overrides": {
40
+ "npm": ">=11.14.1"
41
+ },
39
42
  "devDependencies": {
40
43
  "@semantic-release/changelog": "^6.0.3",
41
44
  "@semantic-release/git": "^10.0.1",
42
45
  "@semantic-release/github": "^12.0.6",
43
46
  "@semantic-release/npm": "^13.1.5",
47
+ "@vitest/coverage-v8": "^4.1.6",
44
48
  "jsdom": "^29.1.1",
45
49
  "semantic-release": "^25.0.3",
46
50
  "typescript": "^6.0.3",