@arcadekit/save 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +52 -2
- package/dist/browser.d.ts +8 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +12 -0
- package/dist/browser.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -3
- package/src/browser.ts +25 -0
- package/src/index.ts +7 -0
- package/src/save.ts +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Thevenet
|
|
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. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @arcadekit/save
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A game's save: records by id (a level's best moves, a hero's best run) under one versioned storage key. Each write re-reads the stored save and merges, so another tab's record isn't lost. A corrupt save is backed up and read as empty, and storage failures are logged rather than raised, so play goes on. `BrowserSaveStorage` in `@arcadekit/save/browser` stores it in localStorage, or in memory if the browser refuses.
|
|
4
|
+
|
|
5
|
+
Part of [arcadekit](https://github.com/lucasthevenet/arcadekit#readme). The root entry is DOM-free.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @arcadekit/save effect @effect/platform-browser
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer dependencies: `effect` (~4.1), and `@effect/platform-browser` (~4.1) for `@arcadekit/save/browser` only.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
Solved levels with their best move count (from the push-puzzle demo):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { makeSaveStore } from "@arcadekit/save";
|
|
21
|
+
import { BrowserSaveStorage } from "@arcadekit/save/browser";
|
|
22
|
+
import { Effect, Schema } from "effect";
|
|
23
|
+
|
|
24
|
+
const LevelRecord = Schema.Struct({
|
|
25
|
+
solved: Schema.Boolean,
|
|
26
|
+
bestMoves: Schema.Int,
|
|
27
|
+
});
|
|
28
|
+
type LevelRecord = typeof LevelRecord.Type;
|
|
29
|
+
|
|
30
|
+
/** The best of both: solved if either says so, with the fewer moves. */
|
|
31
|
+
const mergeRecord = (a: LevelRecord, b: LevelRecord): LevelRecord =>
|
|
32
|
+
!a.solved
|
|
33
|
+
? b
|
|
34
|
+
: !b.solved
|
|
35
|
+
? a
|
|
36
|
+
: { solved: true, bestMoves: Math.min(a.bestMoves, b.bestMoves) };
|
|
37
|
+
|
|
38
|
+
export const recordSolve = (levelId: string, moves: number) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const store = yield* makeSaveStore({
|
|
41
|
+
key: "my-studio/my-game/save",
|
|
42
|
+
field: "levels",
|
|
43
|
+
record: LevelRecord,
|
|
44
|
+
mergeRecord,
|
|
45
|
+
});
|
|
46
|
+
// Neither call fails. `merge` returns the merged records.
|
|
47
|
+
return yield* store.merge({
|
|
48
|
+
[levelId]: { solved: true, bestMoves: moves },
|
|
49
|
+
});
|
|
50
|
+
}).pipe(Effect.provide(BrowserSaveStorage));
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The store works over any Effect `KeyValueStore`, so tests use `KeyValueStore.layerMemory`. See [ADR-0009](https://github.com/lucasthevenet/arcadekit/blob/main/docs/adr/0009-a-save-package-is-earned.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
|
+
/**
|
|
4
|
+
* The browser's localStorage. If the browser won't give access to it, the
|
|
5
|
+
* save lives in memory for this session instead, so the app still loads.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BrowserSaveStorage: Layer.Layer<KeyValueStore.KeyValueStore>;
|
|
8
|
+
//# sourceMappingURL=browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.d.ts","names":[],"sources":["../src/browser.ts"],"mappings":";;;;;;qBAWa,oBAAoB,MAAM,MAAM,cAAc"}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
|
+
import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore";
|
|
4
|
+
// src/browser.ts
|
|
5
|
+
/**
|
|
6
|
+
* The browser's localStorage. If the browser won't give access to it, the
|
|
7
|
+
* save lives in memory for this session instead, so the app still loads.
|
|
8
|
+
*/
|
|
9
|
+
const BrowserSaveStorage = BrowserKeyValueStore.layerLocalStorage.pipe(Layer.catchCause((cause) => Layer.merge(KeyValueStore.layerMemory, Layer.effectDiscard(Effect.logWarning("localStorage isn't available. Saved progress lasts this session only.", cause)))));
|
|
10
|
+
export { BrowserSaveStorage };
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=browser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.js","names":[],"sources":["../src/browser.ts"],"sourcesContent":["import * as BrowserKeyValueStore from \"@effect/platform-browser/BrowserKeyValueStore\";\nimport { Effect, Layer } from \"effect\";\nimport { KeyValueStore } from \"effect/persistence\";\n\n// Storage that needs a browser. Kept out of the root entry so the save logic\n// can be checked and tested without DOM types.\n\n/**\n * The browser's localStorage. If the browser won't give access to it, the\n * save lives in memory for this session instead, so the app still loads.\n */\nexport const BrowserSaveStorage: Layer.Layer<KeyValueStore.KeyValueStore> =\n BrowserKeyValueStore.layerLocalStorage.pipe(\n Layer.catchCause((cause) =>\n Layer.merge(\n KeyValueStore.layerMemory,\n Layer.effectDiscard(\n Effect.logWarning(\n \"localStorage isn't available. Saved progress lasts this session only.\",\n cause\n )\n )\n )\n )\n );\n"],"mappings":";;;;;;;;AAWA,MAAa,qBACX,qBAAqB,kBAAkB,KACrC,MAAM,YAAY,UAChB,MAAM,MACJ,cAAc,aACd,MAAM,cACJ,OAAO,WACL,yEACA,KACF,CACF,CACF,CACF,CACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
|
+
/** Records by id. Ids the content no longer has are kept. */
|
|
4
|
+
type Records<R> = Readonly<Record<string, R>>;
|
|
5
|
+
/** What a game passes in: where its save lives, what one record holds, and how two records merge. */
|
|
6
|
+
interface SaveDefinition<R, E = R> {
|
|
7
|
+
/** The game's storage key, such as `my-studio/<game>/save`. A save that can't be read is copied to `<key>/corrupt`. */
|
|
8
|
+
readonly key: string;
|
|
9
|
+
/** The field of the stored save that holds the records, such as `levels` or `heroes`. */
|
|
10
|
+
readonly field: string;
|
|
11
|
+
/** One record's Schema. */
|
|
12
|
+
readonly record: Schema.Codec<R, E>;
|
|
13
|
+
/** Two records for the same id, as one. Usually the best of both. */
|
|
14
|
+
readonly mergeRecord: (a: R, b: R) => R;
|
|
15
|
+
}
|
|
16
|
+
/** A game's save over a `KeyValueStore`. Neither method fails: storage problems are logged and play goes on. */
|
|
17
|
+
interface SaveStore<R> {
|
|
18
|
+
/** The stored records. A missing save is empty. A corrupt or unknown-version one is backed up, logged and read as empty. */
|
|
19
|
+
readonly load: Effect.Effect<Records<R>>;
|
|
20
|
+
/**
|
|
21
|
+
* Merges `records` into the save. Re-reads the stored save first, so a record
|
|
22
|
+
* another tab wrote isn't lost. Returns the merged records, even when the
|
|
23
|
+
* write fails (that is logged and dropped).
|
|
24
|
+
*/
|
|
25
|
+
readonly merge: (records: Records<R>) => Effect.Effect<Records<R>>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Merges two sets of records by id. Ids only one side has are kept; ids on
|
|
29
|
+
* both sides go through `mergeRecord(a, b)`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const mergeRecords: <R>(mergeRecord: (a: R, b: R) => R) => (a: Records<R>, b: Records<R>) => Records<R>;
|
|
32
|
+
/** Builds a game's save over the `KeyValueStore` in context. */
|
|
33
|
+
export declare const makeSaveStore: <R, E>(definition: SaveDefinition<R, E>) => Effect.Effect<SaveStore<R>, never, KeyValueStore.KeyValueStore>;
|
|
34
|
+
export type { Records, SaveDefinition, SaveStore };
|
|
35
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/save.ts"],"mappings":";;;KAUY,QAAQ,KAAK,SAAS,eAAe;;UAGhC,eAAe,GAAG,IAAI;;WAE5B;;WAEA;;WAEA,QAAQ,OAAO,MAAM,GAAG;;WAExB,cAAc,GAAG,GAAG,GAAG,MAAM;;;UAIvB,UAAU;;WAEhB,MAAM,OAAO,OAAO,QAAQ;;;;;;WAM5B,QAAQ,SAAS,QAAQ,OAAO,OAAO,OAAO,QAAQ;;;;;;qBAUpD,eACV,GAAG,cAAc,GAAG,GAAG,GAAG,MAAM,OAChC,GAAG,QAAQ,IAAI,GAAG,QAAQ,OAAK,QAAQ;;qBA6B7B,gBAAiB,GAAG,GAC/B,YAAY,eAAe,GAAG,OAC7B,OAAO,OAAO,UAAU,WAAW,cAAc"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
|
+
// src/save.ts
|
|
4
|
+
/** Where a save that fails to decode (corrupt, or an unknown version) is copied before it is dropped. */
|
|
5
|
+
const backupKey = (key) => `${key}/corrupt`;
|
|
6
|
+
/**
|
|
7
|
+
* Merges two sets of records by id. Ids only one side has are kept; ids on
|
|
8
|
+
* both sides go through `mergeRecord(a, b)`.
|
|
9
|
+
*/
|
|
10
|
+
const mergeRecords = (mergeRecord) => (a, b) => Object.fromEntries([...Object.entries(a).filter(([id]) => !Object.hasOwn(b, id)), ...Object.entries(b).map(([id, record]) => {
|
|
11
|
+
const other = Object.hasOwn(a, id) ? a[id] : void 0;
|
|
12
|
+
return [id, other === void 0 ? record : mergeRecord(other, record)];
|
|
13
|
+
})]);
|
|
14
|
+
const storedSchema = (field, record) => Schema.Struct({
|
|
15
|
+
version: Schema.Literal(1),
|
|
16
|
+
records: Schema.Record(Schema.String, record)
|
|
17
|
+
}).pipe(Schema.encodeKeys({ records: field }));
|
|
18
|
+
/** Builds a game's save over the `KeyValueStore` in context. */
|
|
19
|
+
const makeSaveStore = (definition) => Effect.gen(function* () {
|
|
20
|
+
const { key } = definition;
|
|
21
|
+
const backup = backupKey(key);
|
|
22
|
+
const empty = {};
|
|
23
|
+
const merge = mergeRecords(definition.mergeRecord);
|
|
24
|
+
const store = yield* KeyValueStore.KeyValueStore;
|
|
25
|
+
const saves = KeyValueStore.toSchemaStore(store, storedSchema(definition.field, definition.record));
|
|
26
|
+
const backUp = Effect.gen(function* () {
|
|
27
|
+
const raw = yield* store.get(key);
|
|
28
|
+
if (raw !== void 0) {
|
|
29
|
+
yield* store.set(backup, raw);
|
|
30
|
+
yield* store.remove(key);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const load = saves.get(key).pipe(Effect.map(Option.match({
|
|
34
|
+
onNone: () => empty,
|
|
35
|
+
onSome: (stored) => stored.records
|
|
36
|
+
})), Effect.catchTag("SchemaError", (error) => Effect.logWarning(`The save can't be read. It is copied to ${backup} and play starts from an empty save.`, error.message).pipe(Effect.andThen(backUp), Effect.as(empty))), Effect.catchTag("KeyValueStoreError", (error) => Effect.logWarning("The save can't be read. Play starts from an empty save.", error.message).pipe(Effect.as(empty))));
|
|
37
|
+
return {
|
|
38
|
+
load,
|
|
39
|
+
merge: (records) => Effect.gen(function* () {
|
|
40
|
+
const merged = merge(yield* load, records);
|
|
41
|
+
yield* saves.set(key, {
|
|
42
|
+
version: 1,
|
|
43
|
+
records: merged
|
|
44
|
+
}).pipe(Effect.catch((error) => Effect.logWarning("The save can't be written.", error.message)));
|
|
45
|
+
return merged;
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
export { makeSaveStore, mergeRecords };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/save.ts"],"sourcesContent":["import { Effect, Option, Schema } from \"effect\";\nimport { KeyValueStore } from \"effect/persistence\";\n\n// A save: what a game keeps between sessions, as records by id (a level's best\n// move count, a hero's best time). It is UI state, not simulation state. The\n// game says what one record holds and how two records for the same id merge;\n// this module keeps the stored shape, the keys, and what to do when storage\n// fails, so neither method fails.\n\n/** Records by id. Ids the content no longer has are kept. */\nexport type Records<R> = Readonly<Record<string, R>>;\n\n/** What a game passes in: where its save lives, what one record holds, and how two records merge. */\nexport interface SaveDefinition<R, E = R> {\n /** The game's storage key, such as `my-studio/<game>/save`. A save that can't be read is copied to `<key>/corrupt`. */\n readonly key: string;\n /** The field of the stored save that holds the records, such as `levels` or `heroes`. */\n readonly field: string;\n /** One record's Schema. */\n readonly record: Schema.Codec<R, E>;\n /** Two records for the same id, as one. Usually the best of both. */\n readonly mergeRecord: (a: R, b: R) => R;\n}\n\n/** A game's save over a `KeyValueStore`. Neither method fails: storage problems are logged and play goes on. */\nexport interface SaveStore<R> {\n /** The stored records. A missing save is empty. A corrupt or unknown-version one is backed up, logged and read as empty. */\n readonly load: Effect.Effect<Records<R>>;\n /**\n * Merges `records` into the save. Re-reads the stored save first, so a record\n * another tab wrote isn't lost. Returns the merged records, even when the\n * write fails (that is logged and dropped).\n */\n readonly merge: (records: Records<R>) => Effect.Effect<Records<R>>;\n}\n\n/** Where a save that fails to decode (corrupt, or an unknown version) is copied before it is dropped. */\nconst backupKey = (key: string): string => `${key}/corrupt`;\n\n/**\n * Merges two sets of records by id. Ids only one side has are kept; ids on\n * both sides go through `mergeRecord(a, b)`.\n */\nexport const mergeRecords =\n <R>(mergeRecord: (a: R, b: R) => R) =>\n (a: Records<R>, b: Records<R>): Records<R> =>\n Object.fromEntries([\n ...Object.entries(a).filter(([id]) => !Object.hasOwn(b, id)),\n ...Object.entries(b).map(([id, record]): [string, R] => {\n const other = Object.hasOwn(a, id) ? a[id] : undefined;\n return [id, other === undefined ? record : mergeRecord(other, record)];\n }),\n ]);\n\n/**\n * The stored shape is `{ version: 1, [field]: records }`. Every save is at\n * version 1 so far. When a game first changes its record shape, add the new\n * version here as a Union member with a way to turn the old into the new.\n */\ninterface StoredSave<R> {\n readonly version: 1;\n readonly records: Records<R>;\n}\n\nconst storedSchema = <R, E>(\n field: string,\n record: Schema.Codec<R, E>\n): Schema.Codec<StoredSave<R>, unknown> =>\n Schema.Struct({\n version: Schema.Literal(1),\n records: Schema.Record(Schema.String, record),\n }).pipe(Schema.encodeKeys({ records: field }));\n\n/** Builds a game's save over the `KeyValueStore` in context. */\nexport const makeSaveStore = <R, E>(\n definition: SaveDefinition<R, E>\n): Effect.Effect<SaveStore<R>, never, KeyValueStore.KeyValueStore> =>\n Effect.gen(function* () {\n const { key } = definition;\n const backup = backupKey(key);\n const empty: Records<R> = {};\n const merge = mergeRecords(definition.mergeRecord);\n const store = yield* KeyValueStore.KeyValueStore;\n const saves = KeyValueStore.toSchemaStore(\n store,\n storedSchema(definition.field, definition.record)\n );\n\n // Copies the unreadable value to the backup key and removes it, so it is backed up once.\n const backUp = Effect.gen(function* () {\n const raw = yield* store.get(key);\n if (raw !== undefined) {\n yield* store.set(backup, raw);\n yield* store.remove(key);\n }\n });\n\n const load: Effect.Effect<Records<R>> = saves.get(key).pipe(\n Effect.map(\n Option.match({\n onNone: () => empty,\n onSome: (stored) => stored.records,\n })\n ),\n Effect.catchTag(\"SchemaError\", (error) =>\n Effect.logWarning(\n `The save can't be read. It is copied to ${backup} and play starts from an empty save.`,\n error.message\n ).pipe(Effect.andThen(backUp), Effect.as(empty))\n ),\n Effect.catchTag(\"KeyValueStoreError\", (error) =>\n Effect.logWarning(\n \"The save can't be read. Play starts from an empty save.\",\n error.message\n ).pipe(Effect.as(empty))\n )\n );\n\n return {\n load,\n merge: (records) =>\n Effect.gen(function* () {\n const merged = merge(yield* load, records);\n yield* saves\n .set(key, { version: 1, records: merged })\n .pipe(\n Effect.catch((error) =>\n Effect.logWarning(\"The save can't be written.\", error.message)\n )\n );\n return merged;\n }),\n };\n });\n"],"mappings":";;;;AAqCA,MAAM,aAAa,QAAwB,GAAG,IAAI;;;;;AAMlD,MAAa,gBACP,iBACH,GAAe,MACd,OAAO,YAAY,CACjB,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,OAAO,GAAG,EAAE,CAAC,GAC3D,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,YAAyB;CACtD,MAAM,QAAQ,OAAO,OAAO,GAAG,EAAE,IAAI,EAAE,MAAM,KAAA;CAC7C,OAAO,CAAC,IAAI,UAAU,KAAA,IAAY,SAAS,YAAY,OAAO,MAAM,CAAC;AACvE,CAAC,CACH,CAAC;AAYL,MAAM,gBACJ,OACA,WAEA,OAAO,OAAO;CACZ,SAAS,OAAO,QAAQ,CAAC;CACzB,SAAS,OAAO,OAAO,OAAO,QAAQ,MAAM;AAC9C,CAAC,CAAC,CAAC,KAAK,OAAO,WAAW,EAAE,SAAS,MAAM,CAAC,CAAC;;AAG/C,MAAa,iBACX,eAEA,OAAO,IAAI,aAAa;CACtB,MAAM,EAAE,QAAQ;CAChB,MAAM,SAAS,UAAU,GAAG;CAC5B,MAAM,QAAoB,CAAC;CAC3B,MAAM,QAAQ,aAAa,WAAW,WAAW;CACjD,MAAM,QAAQ,OAAO,cAAc;CACnC,MAAM,QAAQ,cAAc,cAC1B,OACA,aAAa,WAAW,OAAO,WAAW,MAAM,CAClD;CAGA,MAAM,SAAS,OAAO,IAAI,aAAa;EACrC,MAAM,MAAM,OAAO,MAAM,IAAI,GAAG;EAChC,IAAI,QAAQ,KAAA,GAAW;GACrB,OAAO,MAAM,IAAI,QAAQ,GAAG;GAC5B,OAAO,MAAM,OAAO,GAAG;EACzB;CACF,CAAC;CAED,MAAM,OAAkC,MAAM,IAAI,GAAG,CAAC,CAAC,KACrD,OAAO,IACL,OAAO,MAAM;EACX,cAAc;EACd,SAAS,WAAW,OAAO;CAC7B,CAAC,CACH,GACA,OAAO,SAAS,gBAAgB,UAC9B,OAAO,WACL,2CAA2C,OAAO,uCAClD,MAAM,OACR,CAAC,CAAC,KAAK,OAAO,QAAQ,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC,CACjD,GACA,OAAO,SAAS,uBAAuB,UACrC,OAAO,WACL,2DACA,MAAM,OACR,CAAC,CAAC,KAAK,OAAO,GAAG,KAAK,CAAC,CACzB,CACF;CAEA,OAAO;EACL;EACA,QAAQ,YACN,OAAO,IAAI,aAAa;GACtB,MAAM,SAAS,MAAM,OAAO,MAAM,OAAO;GACzC,OAAO,MACJ,IAAI,KAAK;IAAE,SAAS;IAAG,SAAS;GAAO,CAAC,CAAC,CACzC,KACC,OAAO,OAAO,UACZ,OAAO,WAAW,8BAA8B,MAAM,OAAO,CAC/D,CACF;GACF,OAAO;EACT,CAAC;CACL;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcadekit/save",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Keep a game's records between sessions in a versioned, merging KeyValueStore save",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"arcadekit",
|
|
7
|
+
"effect",
|
|
8
|
+
"game",
|
|
9
|
+
"gamedev",
|
|
10
|
+
"localstorage",
|
|
11
|
+
"save-game"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/lucasthevenet/arcadekit/tree/main/packages/save#readme",
|
|
14
|
+
"bugs": "https://github.com/lucasthevenet/arcadekit/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Lucas Thevenet",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/lucasthevenet/arcadekit.git",
|
|
20
|
+
"directory": "packages/save"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"source": "./src/index.ts",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./browser": {
|
|
36
|
+
"source": "./src/browser.ts",
|
|
37
|
+
"types": "./dist/browser.d.ts",
|
|
38
|
+
"default": "./dist/browser.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@effect/platform-browser": "~4.1.0",
|
|
46
|
+
"effect": "~4.1.2"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@effect/platform-browser": "~4.1.0",
|
|
50
|
+
"effect": "~4.1.2"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@effect/platform-browser": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsdown",
|
|
62
|
+
"typecheck": "tsc -p . && tsc -p tsconfig.root.json"
|
|
63
|
+
}
|
|
6
64
|
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { KeyValueStore } from "effect/persistence";
|
|
4
|
+
|
|
5
|
+
// Storage that needs a browser. Kept out of the root entry so the save logic
|
|
6
|
+
// can be checked and tested without DOM types.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The browser's localStorage. If the browser won't give access to it, the
|
|
10
|
+
* save lives in memory for this session instead, so the app still loads.
|
|
11
|
+
*/
|
|
12
|
+
export const BrowserSaveStorage: Layer.Layer<KeyValueStore.KeyValueStore> =
|
|
13
|
+
BrowserKeyValueStore.layerLocalStorage.pipe(
|
|
14
|
+
Layer.catchCause((cause) =>
|
|
15
|
+
Layer.merge(
|
|
16
|
+
KeyValueStore.layerMemory,
|
|
17
|
+
Layer.effectDiscard(
|
|
18
|
+
Effect.logWarning(
|
|
19
|
+
"localStorage isn't available. Saved progress lasts this session only.",
|
|
20
|
+
cause
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
);
|
package/src/index.ts
ADDED
package/src/save.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { KeyValueStore } from "effect/persistence";
|
|
3
|
+
|
|
4
|
+
// A save: what a game keeps between sessions, as records by id (a level's best
|
|
5
|
+
// move count, a hero's best time). It is UI state, not simulation state. The
|
|
6
|
+
// game says what one record holds and how two records for the same id merge;
|
|
7
|
+
// this module keeps the stored shape, the keys, and what to do when storage
|
|
8
|
+
// fails, so neither method fails.
|
|
9
|
+
|
|
10
|
+
/** Records by id. Ids the content no longer has are kept. */
|
|
11
|
+
export type Records<R> = Readonly<Record<string, R>>;
|
|
12
|
+
|
|
13
|
+
/** What a game passes in: where its save lives, what one record holds, and how two records merge. */
|
|
14
|
+
export interface SaveDefinition<R, E = R> {
|
|
15
|
+
/** The game's storage key, such as `my-studio/<game>/save`. A save that can't be read is copied to `<key>/corrupt`. */
|
|
16
|
+
readonly key: string;
|
|
17
|
+
/** The field of the stored save that holds the records, such as `levels` or `heroes`. */
|
|
18
|
+
readonly field: string;
|
|
19
|
+
/** One record's Schema. */
|
|
20
|
+
readonly record: Schema.Codec<R, E>;
|
|
21
|
+
/** Two records for the same id, as one. Usually the best of both. */
|
|
22
|
+
readonly mergeRecord: (a: R, b: R) => R;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A game's save over a `KeyValueStore`. Neither method fails: storage problems are logged and play goes on. */
|
|
26
|
+
export interface SaveStore<R> {
|
|
27
|
+
/** The stored records. A missing save is empty. A corrupt or unknown-version one is backed up, logged and read as empty. */
|
|
28
|
+
readonly load: Effect.Effect<Records<R>>;
|
|
29
|
+
/**
|
|
30
|
+
* Merges `records` into the save. Re-reads the stored save first, so a record
|
|
31
|
+
* another tab wrote isn't lost. Returns the merged records, even when the
|
|
32
|
+
* write fails (that is logged and dropped).
|
|
33
|
+
*/
|
|
34
|
+
readonly merge: (records: Records<R>) => Effect.Effect<Records<R>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a save that fails to decode (corrupt, or an unknown version) is copied before it is dropped. */
|
|
38
|
+
const backupKey = (key: string): string => `${key}/corrupt`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Merges two sets of records by id. Ids only one side has are kept; ids on
|
|
42
|
+
* both sides go through `mergeRecord(a, b)`.
|
|
43
|
+
*/
|
|
44
|
+
export const mergeRecords =
|
|
45
|
+
<R>(mergeRecord: (a: R, b: R) => R) =>
|
|
46
|
+
(a: Records<R>, b: Records<R>): Records<R> =>
|
|
47
|
+
Object.fromEntries([
|
|
48
|
+
...Object.entries(a).filter(([id]) => !Object.hasOwn(b, id)),
|
|
49
|
+
...Object.entries(b).map(([id, record]): [string, R] => {
|
|
50
|
+
const other = Object.hasOwn(a, id) ? a[id] : undefined;
|
|
51
|
+
return [id, other === undefined ? record : mergeRecord(other, record)];
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The stored shape is `{ version: 1, [field]: records }`. Every save is at
|
|
57
|
+
* version 1 so far. When a game first changes its record shape, add the new
|
|
58
|
+
* version here as a Union member with a way to turn the old into the new.
|
|
59
|
+
*/
|
|
60
|
+
interface StoredSave<R> {
|
|
61
|
+
readonly version: 1;
|
|
62
|
+
readonly records: Records<R>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const storedSchema = <R, E>(
|
|
66
|
+
field: string,
|
|
67
|
+
record: Schema.Codec<R, E>
|
|
68
|
+
): Schema.Codec<StoredSave<R>, unknown> =>
|
|
69
|
+
Schema.Struct({
|
|
70
|
+
version: Schema.Literal(1),
|
|
71
|
+
records: Schema.Record(Schema.String, record),
|
|
72
|
+
}).pipe(Schema.encodeKeys({ records: field }));
|
|
73
|
+
|
|
74
|
+
/** Builds a game's save over the `KeyValueStore` in context. */
|
|
75
|
+
export const makeSaveStore = <R, E>(
|
|
76
|
+
definition: SaveDefinition<R, E>
|
|
77
|
+
): Effect.Effect<SaveStore<R>, never, KeyValueStore.KeyValueStore> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const { key } = definition;
|
|
80
|
+
const backup = backupKey(key);
|
|
81
|
+
const empty: Records<R> = {};
|
|
82
|
+
const merge = mergeRecords(definition.mergeRecord);
|
|
83
|
+
const store = yield* KeyValueStore.KeyValueStore;
|
|
84
|
+
const saves = KeyValueStore.toSchemaStore(
|
|
85
|
+
store,
|
|
86
|
+
storedSchema(definition.field, definition.record)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Copies the unreadable value to the backup key and removes it, so it is backed up once.
|
|
90
|
+
const backUp = Effect.gen(function* () {
|
|
91
|
+
const raw = yield* store.get(key);
|
|
92
|
+
if (raw !== undefined) {
|
|
93
|
+
yield* store.set(backup, raw);
|
|
94
|
+
yield* store.remove(key);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const load: Effect.Effect<Records<R>> = saves.get(key).pipe(
|
|
99
|
+
Effect.map(
|
|
100
|
+
Option.match({
|
|
101
|
+
onNone: () => empty,
|
|
102
|
+
onSome: (stored) => stored.records,
|
|
103
|
+
})
|
|
104
|
+
),
|
|
105
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
106
|
+
Effect.logWarning(
|
|
107
|
+
`The save can't be read. It is copied to ${backup} and play starts from an empty save.`,
|
|
108
|
+
error.message
|
|
109
|
+
).pipe(Effect.andThen(backUp), Effect.as(empty))
|
|
110
|
+
),
|
|
111
|
+
Effect.catchTag("KeyValueStoreError", (error) =>
|
|
112
|
+
Effect.logWarning(
|
|
113
|
+
"The save can't be read. Play starts from an empty save.",
|
|
114
|
+
error.message
|
|
115
|
+
).pipe(Effect.as(empty))
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
load,
|
|
121
|
+
merge: (records) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
const merged = merge(yield* load, records);
|
|
124
|
+
yield* saves
|
|
125
|
+
.set(key, { version: 1, records: merged })
|
|
126
|
+
.pipe(
|
|
127
|
+
Effect.catch((error) =>
|
|
128
|
+
Effect.logWarning("The save can't be written.", error.message)
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
return merged;
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
});
|