@platonic-dice/dice 2.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/README.md +50 -0
- package/dist/__tests__/components/die-history/history-cache.spec.js +106 -0
- package/dist/__tests__/components/die-history/internal/roll-record-manager/internal/roll-record-storage.spec.js +40 -0
- package/dist/__tests__/components/die-history/internal/roll-record-manager/roll-record-manager.spec.js +112 -0
- package/dist/__tests__/components/die-history/internal/roll-record-validator.spec.js +38 -0
- package/dist/__tests__/components/die-history/roll-record-factory.spec.js +43 -0
- package/dist/__tests__/die.spec.js +140 -0
- package/dist/__tests__/types/roll-record.types.spec.js +41 -0
- package/dist/src/components/die-history/history-cache.js +123 -0
- package/dist/src/components/die-history/index.js +2 -0
- package/dist/src/components/die-history/internal/index.js +2 -0
- package/dist/src/components/die-history/internal/roll-record-manager/index.js +1 -0
- package/dist/src/components/die-history/internal/roll-record-manager/internal/index.js +1 -0
- package/dist/src/components/die-history/internal/roll-record-manager/internal/roll-record-storage.js +66 -0
- package/dist/src/components/die-history/internal/roll-record-manager/roll-record-manager.js +124 -0
- package/dist/src/components/die-history/internal/roll-record-validator.js +43 -0
- package/dist/src/components/die-history/roll-record-factory.js +55 -0
- package/dist/src/components/index.js +1 -0
- package/dist/src/die.js +226 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/types/roll-record.types.js +1 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @platonic-dice/dice
|
|
2
|
+
|
|
3
|
+
Persistent dice objects with roll history and TypeScript support. This package builds on top of `@platonic-dice/core` and provides classes such as `Die` which maintain roll history, validators, and utilities for consuming applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @platonic-dice/dice
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
CommonJS:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { Die } = require("@platonic-dice/dice");
|
|
17
|
+
const { DieType } = require("@platonic-dice/core");
|
|
18
|
+
|
|
19
|
+
const d20 = new Die(DieType.D20);
|
|
20
|
+
console.log(d20.roll());
|
|
21
|
+
console.log(d20.history.getAll());
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
TypeScript:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { Die } from "@platonic-dice/dice";
|
|
28
|
+
import { DieType } from "@platonic-dice/core";
|
|
29
|
+
|
|
30
|
+
const d20 = new Die(DieType.D20);
|
|
31
|
+
console.log(d20.roll());
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Build
|
|
35
|
+
|
|
36
|
+
This package is written in TypeScript and compiles to `dist/`.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd packages/dice
|
|
40
|
+
npm run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Notes on publishing
|
|
44
|
+
|
|
45
|
+
- This package depends on `@platonic-dice/core`. When publishing both packages in the same release, ensure both `package.json` versions are bumped to the same release tag (the repository's release workflow can publish matching versions automatically).
|
|
46
|
+
- The package is scoped (`@platonic-dice/dice`) — make sure the npm scope exists for your account or organization before publishing.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT — see the repository `LICENSE` file.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
3
|
+
import { HistoryCache } from "@dice/components/die-history";
|
|
4
|
+
describe("RollHistoryCache", () => {
|
|
5
|
+
let cache;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
cache = new HistoryCache({
|
|
8
|
+
maxRecordsPerKey: 3,
|
|
9
|
+
maxKeys: 2,
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
it("starts empty", () => {
|
|
13
|
+
expect(cache.activeManager).toBeUndefined();
|
|
14
|
+
expect(cache.getAll()).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
it("throws if add called before setting active key", () => {
|
|
17
|
+
expect(() => cache.add({ roll: 1, timestamp: new Date() })).toThrow(/No active history key set/);
|
|
18
|
+
});
|
|
19
|
+
it("creates and sets an active key", () => {
|
|
20
|
+
cache.setActiveKey("first");
|
|
21
|
+
expect(cache.activeManager).toBeDefined();
|
|
22
|
+
expect(cache.getAll()).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
it("adds records to the active key", () => {
|
|
25
|
+
cache.setActiveKey("key1");
|
|
26
|
+
const record = { roll: 5, timestamp: new Date() };
|
|
27
|
+
cache.add(record);
|
|
28
|
+
const all = cache.getAll(true);
|
|
29
|
+
expect(all.length).toBe(1);
|
|
30
|
+
expect(all[0].roll).toBe(5);
|
|
31
|
+
});
|
|
32
|
+
it("respects maxRecordsPerKey", () => {
|
|
33
|
+
cache.setActiveKey("key1");
|
|
34
|
+
const now = new Date();
|
|
35
|
+
for (let i = 1; i <= 5; i++) {
|
|
36
|
+
cache.add({ roll: i, timestamp: new Date(now.getTime() + i) });
|
|
37
|
+
}
|
|
38
|
+
const all = cache.getAll(true);
|
|
39
|
+
expect(all.length).toBe(3); // capped
|
|
40
|
+
expect(all[0].roll).toBe(3); // oldest removed
|
|
41
|
+
expect(all[2].roll).toBe(5);
|
|
42
|
+
});
|
|
43
|
+
it("switching keys creates independent histories", () => {
|
|
44
|
+
cache.setActiveKey("A");
|
|
45
|
+
cache.add({ roll: 1, timestamp: new Date() });
|
|
46
|
+
cache.setActiveKey("B");
|
|
47
|
+
cache.add({ roll: 2, timestamp: new Date() });
|
|
48
|
+
cache.setActiveKey("A");
|
|
49
|
+
expect(cache.getAll(true)[0].roll).toBe(1);
|
|
50
|
+
cache.setActiveKey("B");
|
|
51
|
+
expect(cache.getAll(true)[0].roll).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
it("evicts oldest key when maxKeys exceeded", () => {
|
|
54
|
+
cache.setActiveKey("K1");
|
|
55
|
+
cache.add({ roll: 1, timestamp: new Date() });
|
|
56
|
+
cache.setActiveKey("K2");
|
|
57
|
+
cache.add({ roll: 2, timestamp: new Date() });
|
|
58
|
+
// Max keys = 2, next key should evict K1
|
|
59
|
+
cache.setActiveKey("K3");
|
|
60
|
+
cache.add({ roll: 3, timestamp: new Date() });
|
|
61
|
+
const report = cache.report({ verbose: true });
|
|
62
|
+
expect(Object.keys(report)).not.toContain("K1");
|
|
63
|
+
expect(Object.keys(report)).toContain("K2");
|
|
64
|
+
expect(Object.keys(report)).toContain("K3");
|
|
65
|
+
});
|
|
66
|
+
it("clearActive clears only the active key", () => {
|
|
67
|
+
cache.setActiveKey("A");
|
|
68
|
+
cache.add({ roll: 1, timestamp: new Date() });
|
|
69
|
+
cache.setActiveKey("B");
|
|
70
|
+
cache.add({ roll: 2, timestamp: new Date() });
|
|
71
|
+
cache.setActiveKey("A");
|
|
72
|
+
cache.clearActive();
|
|
73
|
+
expect(cache.getAll(true)).toEqual([]);
|
|
74
|
+
cache.setActiveKey("B");
|
|
75
|
+
expect(cache.getAll(true).length).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
it("clearAll clears everything", () => {
|
|
78
|
+
cache.setActiveKey("X");
|
|
79
|
+
cache.add({ roll: 1, timestamp: new Date() });
|
|
80
|
+
cache.setActiveKey("Y");
|
|
81
|
+
cache.add({ roll: 2, timestamp: new Date() });
|
|
82
|
+
cache.clearAll();
|
|
83
|
+
expect(cache.getAll()).toEqual([]);
|
|
84
|
+
expect(Object.keys(cache.report())).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
it("report returns correct verbose and non-verbose data", () => {
|
|
87
|
+
cache.setActiveKey("R1");
|
|
88
|
+
cache.add({ roll: 10, timestamp: new Date() });
|
|
89
|
+
const reportsVerbose = cache.report({ verbose: true });
|
|
90
|
+
const reportsNonVerbose = cache.report({ verbose: false });
|
|
91
|
+
expect(reportsVerbose["R1"][0]).toHaveProperty("timestamp");
|
|
92
|
+
expect(reportsNonVerbose["R1"][0]).not.toHaveProperty("timestamp");
|
|
93
|
+
});
|
|
94
|
+
it("toString returns a readable summary", () => {
|
|
95
|
+
cache.setActiveKey("Active");
|
|
96
|
+
expect(cache.toString()).toMatch(/HistoryCache: 1 keys \(active: Active\)/);
|
|
97
|
+
});
|
|
98
|
+
it("toJSON returns object with arrays of RollRecords", () => {
|
|
99
|
+
cache.setActiveKey("J");
|
|
100
|
+
const record = { roll: 7, timestamp: new Date() };
|
|
101
|
+
cache.add(record);
|
|
102
|
+
const json = cache.toJSON();
|
|
103
|
+
expect(json).toHaveProperty("J");
|
|
104
|
+
expect(json["J"][0].roll).toBe(7);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import RollRecordStorage from "@dice/components/die-history/internal/roll-record-manager/internal/roll-record-storage";
|
|
3
|
+
describe("RollRecordStorage", () => {
|
|
4
|
+
it("evicts oldest when capacity exceeded", () => {
|
|
5
|
+
const s = new RollRecordStorage(2);
|
|
6
|
+
s.add({ roll: 1, timestamp: new Date() });
|
|
7
|
+
s.add({ roll: 2, timestamp: new Date() });
|
|
8
|
+
s.add({ roll: 3, timestamp: new Date() });
|
|
9
|
+
expect(s.size).toBe(2);
|
|
10
|
+
expect(s.full.map((r) => r.roll)).toEqual([2, 3]);
|
|
11
|
+
});
|
|
12
|
+
it("last returns correct slice and order", () => {
|
|
13
|
+
const s = new RollRecordStorage(10);
|
|
14
|
+
s.add({ roll: 1, timestamp: new Date() });
|
|
15
|
+
s.add({ roll: 2, timestamp: new Date() });
|
|
16
|
+
s.add({ roll: 3, timestamp: new Date() });
|
|
17
|
+
s.add({ roll: 4, timestamp: new Date() });
|
|
18
|
+
const last2 = s.last(2);
|
|
19
|
+
expect(last2.map((r) => r.roll)).toEqual([3, 4]);
|
|
20
|
+
});
|
|
21
|
+
it("getters return array copies (mutating returned arrays doesn't affect storage)", () => {
|
|
22
|
+
const s = new RollRecordStorage(3);
|
|
23
|
+
s.add({ roll: 1, timestamp: new Date() });
|
|
24
|
+
const full = s.full;
|
|
25
|
+
full.pop();
|
|
26
|
+
expect(s.size).toBe(1);
|
|
27
|
+
const last = s.last(1);
|
|
28
|
+
// mutating the returned array should not affect internal storage length
|
|
29
|
+
last.pop();
|
|
30
|
+
expect(s.size).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
it("throws for invalid constructor args and parameters", () => {
|
|
33
|
+
// call via any to test runtime throw without TS compile errors
|
|
34
|
+
expect(() => new RollRecordStorage(0)).toThrow(TypeError);
|
|
35
|
+
const s = new RollRecordStorage(2);
|
|
36
|
+
expect(() => s.last(0)).toThrow(TypeError);
|
|
37
|
+
// pass null to add
|
|
38
|
+
expect(() => s.add(null)).toThrow(TypeError);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Outcome } from "@platonic-dice/core";
|
|
3
|
+
import { DEFAULT_MAX_RECORDS, RollRecordManager, } from "@dice/components/die-history/internal";
|
|
4
|
+
describe("RollRecordManager", () => {
|
|
5
|
+
let manager;
|
|
6
|
+
const dieRoll = {
|
|
7
|
+
roll: 5,
|
|
8
|
+
timestamp: new Date(),
|
|
9
|
+
};
|
|
10
|
+
const modifiedDieRoll = {
|
|
11
|
+
roll: 3,
|
|
12
|
+
modified: 1,
|
|
13
|
+
timestamp: new Date(),
|
|
14
|
+
};
|
|
15
|
+
const targetDieRoll = {
|
|
16
|
+
roll: 20,
|
|
17
|
+
outcome: Outcome.Success,
|
|
18
|
+
timestamp: new Date(),
|
|
19
|
+
};
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
manager = new RollRecordManager();
|
|
22
|
+
});
|
|
23
|
+
it("should initialize empty with default maxRecords", () => {
|
|
24
|
+
expect(manager.length).toBe(0);
|
|
25
|
+
expect(manager.maxRecordsCount).toBe(DEFAULT_MAX_RECORDS);
|
|
26
|
+
expect(manager.full).toEqual([]);
|
|
27
|
+
expect(manager.all).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it("should add DieRollRecord correctly", () => {
|
|
30
|
+
manager.add(dieRoll);
|
|
31
|
+
expect(manager.length).toBe(1);
|
|
32
|
+
expect(manager.full[0]).toEqual(dieRoll);
|
|
33
|
+
expect(manager.all[0]).toEqual({ roll: dieRoll.roll });
|
|
34
|
+
});
|
|
35
|
+
it("should add ModifiedDieRollRecord correctly", () => {
|
|
36
|
+
manager.add(modifiedDieRoll);
|
|
37
|
+
expect(manager.length).toBe(1);
|
|
38
|
+
expect(manager.full[0]).toEqual(modifiedDieRoll);
|
|
39
|
+
expect(manager.all[0]).toEqual({
|
|
40
|
+
roll: modifiedDieRoll.roll,
|
|
41
|
+
modified: modifiedDieRoll.modified,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it("should add TargetDieRollRecord correctly", () => {
|
|
45
|
+
manager.add(targetDieRoll);
|
|
46
|
+
expect(manager.length).toBe(1);
|
|
47
|
+
expect(manager.full[0]).toEqual(targetDieRoll);
|
|
48
|
+
expect(manager.all[0]).toEqual({
|
|
49
|
+
roll: targetDieRoll.roll,
|
|
50
|
+
outcome: targetDieRoll.outcome,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it("should throw TypeError for invalid records", () => {
|
|
54
|
+
// @ts-expect-error testing runtime validation
|
|
55
|
+
expect(() => manager.add({})).toThrow(TypeError);
|
|
56
|
+
// @ts-expect-error testing runtime validation
|
|
57
|
+
expect(() => manager.add(null)).toThrow(TypeError);
|
|
58
|
+
// @ts-expect-error testing runtime validation
|
|
59
|
+
expect(() => manager.add({ roll: 5, foo: "bar" })).toThrow(TypeError);
|
|
60
|
+
});
|
|
61
|
+
it("should maintain maxRecords correctly", () => {
|
|
62
|
+
const smallManager = new RollRecordManager(2);
|
|
63
|
+
smallManager.add({ roll: 1, timestamp: new Date() });
|
|
64
|
+
smallManager.add({ roll: 2, timestamp: new Date() });
|
|
65
|
+
smallManager.add({ roll: 3, timestamp: new Date() }); // should push out oldest
|
|
66
|
+
expect(smallManager.length).toBe(2);
|
|
67
|
+
expect(smallManager.full.map((r) => r.roll)).toEqual([2, 3]);
|
|
68
|
+
});
|
|
69
|
+
it("should return last N records with last()", () => {
|
|
70
|
+
manager.add(dieRoll);
|
|
71
|
+
manager.add(modifiedDieRoll);
|
|
72
|
+
manager.add(targetDieRoll);
|
|
73
|
+
const lastOne = manager.last();
|
|
74
|
+
expect(lastOne).toEqual([
|
|
75
|
+
{ roll: targetDieRoll.roll, outcome: targetDieRoll.outcome },
|
|
76
|
+
]);
|
|
77
|
+
const lastTwo = manager.last(2);
|
|
78
|
+
expect(lastTwo).toEqual([
|
|
79
|
+
{ roll: modifiedDieRoll.roll, modified: modifiedDieRoll.modified },
|
|
80
|
+
{ roll: targetDieRoll.roll, outcome: targetDieRoll.outcome },
|
|
81
|
+
]);
|
|
82
|
+
const verboseLastTwo = manager.last(2, true);
|
|
83
|
+
expect(verboseLastTwo).toEqual([modifiedDieRoll, targetDieRoll]);
|
|
84
|
+
});
|
|
85
|
+
it("should report records with report()", () => {
|
|
86
|
+
manager.add(dieRoll);
|
|
87
|
+
manager.add(modifiedDieRoll);
|
|
88
|
+
manager.add(targetDieRoll);
|
|
89
|
+
expect(manager.report()).toEqual(manager.all);
|
|
90
|
+
expect(manager.report({ verbose: true })).toEqual(manager.full);
|
|
91
|
+
expect(manager.report({ limit: 2 })).toEqual(manager.all.slice(-2));
|
|
92
|
+
expect(manager.report({ limit: 2, verbose: true })).toEqual(manager.full.slice(-2));
|
|
93
|
+
});
|
|
94
|
+
it("should clear records with clear()", () => {
|
|
95
|
+
manager.add(dieRoll);
|
|
96
|
+
manager.clear();
|
|
97
|
+
expect(manager.length).toBe(0);
|
|
98
|
+
expect(manager.full).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
it("toString() should reflect last roll", () => {
|
|
101
|
+
expect(manager.toString()).toContain("empty");
|
|
102
|
+
manager.add(dieRoll);
|
|
103
|
+
const str = manager.toString();
|
|
104
|
+
expect(str).toContain("1/");
|
|
105
|
+
expect(str).toContain(dieRoll.roll.toString());
|
|
106
|
+
expect(str).toContain(dieRoll.timestamp.toISOString());
|
|
107
|
+
});
|
|
108
|
+
it("toJSON() should return full records", () => {
|
|
109
|
+
manager.add(dieRoll);
|
|
110
|
+
expect(manager.toJSON()).toEqual(manager.full);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Outcome } from "@platonic-dice/core";
|
|
3
|
+
import { isDieRollRecord, isModifiedDieRollRecord, isTargetDieRollRecord, stripTimestamp, } from "@dice/components/die-history/internal";
|
|
4
|
+
describe("RollRecord validator", () => {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
const dieRecord = {
|
|
7
|
+
roll: 5,
|
|
8
|
+
timestamp: now,
|
|
9
|
+
};
|
|
10
|
+
const modifiedRecord = {
|
|
11
|
+
roll: 10,
|
|
12
|
+
modified: 12,
|
|
13
|
+
timestamp: now,
|
|
14
|
+
};
|
|
15
|
+
const targetRecord = {
|
|
16
|
+
roll: 17,
|
|
17
|
+
outcome: Outcome.Success,
|
|
18
|
+
timestamp: now,
|
|
19
|
+
};
|
|
20
|
+
it("recognises valid shapes", () => {
|
|
21
|
+
expect(isDieRollRecord(dieRecord)).toBe(true);
|
|
22
|
+
expect(isModifiedDieRollRecord(modifiedRecord)).toBe(true);
|
|
23
|
+
expect(isTargetDieRollRecord(targetRecord)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it("rejects invalid shapes", () => {
|
|
26
|
+
// missing timestamp
|
|
27
|
+
expect(isDieRollRecord({ roll: 5 })).toBe(false);
|
|
28
|
+
// wrong modified type
|
|
29
|
+
expect(isModifiedDieRollRecord({ roll: 5, modified: "x" })).toBe(false);
|
|
30
|
+
// invalid outcome
|
|
31
|
+
expect(isTargetDieRollRecord({ roll: 5, outcome: "nope" })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
it("stripTimestamp removes timestamp and preserves other fields", () => {
|
|
34
|
+
const stripped = stripTimestamp(dieRecord);
|
|
35
|
+
expect(stripped.timestamp).toBeUndefined();
|
|
36
|
+
expect(stripped.roll).toBe(dieRecord.roll);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { RollRecordFactory } from "@dice/components/die-history";
|
|
3
|
+
import { DieType } from "@platonic-dice/core";
|
|
4
|
+
describe("RollRecordFactory", () => {
|
|
5
|
+
const factory = new RollRecordFactory();
|
|
6
|
+
it("should create a valid DieRollRecord for normal rolls", () => {
|
|
7
|
+
const record = factory.createNormalRoll(DieType.D6);
|
|
8
|
+
expect(record).toMatchObject({
|
|
9
|
+
roll: expect.any(Number),
|
|
10
|
+
timestamp: expect.any(Date),
|
|
11
|
+
});
|
|
12
|
+
expect(record.roll).toBeGreaterThanOrEqual(1);
|
|
13
|
+
expect(record.roll).toBeLessThanOrEqual(6);
|
|
14
|
+
});
|
|
15
|
+
it("should create a valid ModifiedDieRollRecord for modified rolls", () => {
|
|
16
|
+
const record = factory.createModifiedRoll(DieType.D20, (n) => n + 2);
|
|
17
|
+
expect(record).toMatchObject({
|
|
18
|
+
roll: expect.any(Number),
|
|
19
|
+
modified: expect.any(Number),
|
|
20
|
+
timestamp: expect.any(Date),
|
|
21
|
+
});
|
|
22
|
+
expect(record.roll).toBeGreaterThanOrEqual(1);
|
|
23
|
+
expect(record.roll).toBeLessThanOrEqual(20);
|
|
24
|
+
expect(record.modified).toBe(record.roll + 2);
|
|
25
|
+
});
|
|
26
|
+
it("should create a valid TestDieRollRecord for test rolls", () => {
|
|
27
|
+
const record = factory.createTestRoll(DieType.D10, {
|
|
28
|
+
testType: "at_least",
|
|
29
|
+
target: 5,
|
|
30
|
+
});
|
|
31
|
+
expect(record).toMatchObject({
|
|
32
|
+
roll: expect.any(Number),
|
|
33
|
+
outcome: expect.any(String),
|
|
34
|
+
timestamp: expect.any(Date),
|
|
35
|
+
});
|
|
36
|
+
expect(record.roll).toBeGreaterThanOrEqual(1);
|
|
37
|
+
expect(record.roll).toBeLessThanOrEqual(10);
|
|
38
|
+
expect(["Success", "Failure"].map((o) => o.toLowerCase())).toContain(record.outcome.toLowerCase());
|
|
39
|
+
});
|
|
40
|
+
it("should throw an error for invalid RollType in createNormalRoll", () => {
|
|
41
|
+
expect(() => factory.createNormalRoll(DieType.D6, "InvalidRollType")).toThrow(TypeError);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
3
|
+
import { Die } from "@dice/die";
|
|
4
|
+
import { DieType, RollType, roll as coreRoll, rollMod as coreRollMod, rollTest as coreRollTest, } from "@platonic-dice/core";
|
|
5
|
+
// ------------------------
|
|
6
|
+
// MOCK @platonic-dice/core
|
|
7
|
+
// ------------------------
|
|
8
|
+
vi.mock("@platonic-dice/core", async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal();
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
roll: vi.fn(),
|
|
13
|
+
rollMod: vi.fn(),
|
|
14
|
+
rollTest: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
// ------------------------
|
|
18
|
+
// TEST SUITE
|
|
19
|
+
// ------------------------
|
|
20
|
+
describe("Die class", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
// ---------------------
|
|
25
|
+
// CONSTRUCTOR VALIDATION
|
|
26
|
+
// ---------------------
|
|
27
|
+
it("creates a die with a valid type", () => {
|
|
28
|
+
const die = new Die(DieType.D6);
|
|
29
|
+
expect(die.type).toBe(DieType.D6);
|
|
30
|
+
expect(die.result).toBe(undefined);
|
|
31
|
+
expect(die.history("normal")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
it("throws for invalid die type", () => {
|
|
34
|
+
expect(() => new Die("INVALID")).toThrowError(/Invalid die type/);
|
|
35
|
+
});
|
|
36
|
+
// ---------------------
|
|
37
|
+
// FACE COUNT
|
|
38
|
+
// ---------------------
|
|
39
|
+
it("returns the correct faceCount", () => {
|
|
40
|
+
expect(new Die(DieType.D4).faceCount).toBe(4);
|
|
41
|
+
expect(new Die(DieType.D6).faceCount).toBe(6);
|
|
42
|
+
expect(new Die(DieType.D20).faceCount).toBe(20);
|
|
43
|
+
});
|
|
44
|
+
// ---------------------
|
|
45
|
+
// BASIC ROLL
|
|
46
|
+
// ---------------------
|
|
47
|
+
it("rolls and records a basic DieRollRecord", () => {
|
|
48
|
+
const die = new Die(DieType.D6);
|
|
49
|
+
coreRoll.mockReturnValue(4);
|
|
50
|
+
const result = die.roll();
|
|
51
|
+
expect(result).toBe(4);
|
|
52
|
+
expect(die.result).toBe(4);
|
|
53
|
+
const history = die.history("normal", true);
|
|
54
|
+
expect(history.length).toBe(1);
|
|
55
|
+
expect(history[0]).toMatchObject({ roll: 4 });
|
|
56
|
+
});
|
|
57
|
+
it("passes through rollType to coreRoll()", () => {
|
|
58
|
+
const die = new Die(DieType.D20);
|
|
59
|
+
coreRoll.mockReturnValue(17);
|
|
60
|
+
die.roll(RollType.Advantage);
|
|
61
|
+
expect(coreRoll).toHaveBeenCalledWith(DieType.D20, RollType.Advantage);
|
|
62
|
+
});
|
|
63
|
+
it("throws for invalid rollType", () => {
|
|
64
|
+
const die = new Die(DieType.D8);
|
|
65
|
+
expect(() => die.roll("INVALID")).toThrowError(/Invalid roll type/);
|
|
66
|
+
});
|
|
67
|
+
// ---------------------
|
|
68
|
+
// MODIFIED ROLL
|
|
69
|
+
// ---------------------
|
|
70
|
+
it("rollMod records ModifiedDieRollRecord", () => {
|
|
71
|
+
const die = new Die(DieType.D6);
|
|
72
|
+
coreRollMod.mockReturnValue({ base: 3, modified: 5 });
|
|
73
|
+
const result = die.rollMod((n) => n + 2);
|
|
74
|
+
expect(result).toBe(5);
|
|
75
|
+
expect(die.result).toBe(5);
|
|
76
|
+
const history = die.history("modifier", true);
|
|
77
|
+
expect(history.length).toBe(1);
|
|
78
|
+
expect(history[0]).toMatchObject({ roll: 3, modified: 5 });
|
|
79
|
+
});
|
|
80
|
+
// ---------------------
|
|
81
|
+
// TEST ROLL
|
|
82
|
+
// ---------------------
|
|
83
|
+
it("rollTest records TargetDieRollRecord", () => {
|
|
84
|
+
const die = new Die(DieType.D6);
|
|
85
|
+
coreRollTest.mockReturnValue({ base: 4, outcome: "success" });
|
|
86
|
+
const result = die.rollTest({ testType: "at_least", target: 3 });
|
|
87
|
+
expect(result).toBe(4);
|
|
88
|
+
expect(die.result).toBe(4);
|
|
89
|
+
const history = die.history("test", true);
|
|
90
|
+
expect(history.length).toBe(1);
|
|
91
|
+
expect(history[0]).toMatchObject({ roll: 4, outcome: "success" });
|
|
92
|
+
});
|
|
93
|
+
// ---------------------
|
|
94
|
+
// HISTORY ACCESS
|
|
95
|
+
// ---------------------
|
|
96
|
+
it("history returns timestamp-stripped records when verbose=false", () => {
|
|
97
|
+
const die = new Die(DieType.D6);
|
|
98
|
+
coreRoll.mockReturnValue(2);
|
|
99
|
+
die.roll();
|
|
100
|
+
const hist = die.history("normal", false);
|
|
101
|
+
expect(hist[0]).not.toHaveProperty("timestamp");
|
|
102
|
+
const histVerbose = die.history("normal", true);
|
|
103
|
+
expect(histVerbose[0]).toHaveProperty("timestamp");
|
|
104
|
+
});
|
|
105
|
+
// ---------------------
|
|
106
|
+
// RESET
|
|
107
|
+
// ---------------------
|
|
108
|
+
it("reset clears latest result and optionally all histories", () => {
|
|
109
|
+
const die = new Die(DieType.D6);
|
|
110
|
+
coreRoll.mockReturnValue(5);
|
|
111
|
+
die.roll();
|
|
112
|
+
die.reset();
|
|
113
|
+
expect(die.result).toBeUndefined();
|
|
114
|
+
die.roll();
|
|
115
|
+
die.reset(true);
|
|
116
|
+
expect(die.result).toBeUndefined();
|
|
117
|
+
expect(die.history("normal")).toEqual([]);
|
|
118
|
+
expect(die.history("modifier")).toEqual([]);
|
|
119
|
+
expect(die.history("test")).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
// ---------------------
|
|
122
|
+
// toString / toJSON
|
|
123
|
+
// ---------------------
|
|
124
|
+
it("toString reports correctly", () => {
|
|
125
|
+
const die = new Die(DieType.D6);
|
|
126
|
+
expect(die.toString()).toMatch(/not rolled yet/);
|
|
127
|
+
coreRoll.mockReturnValue(3);
|
|
128
|
+
die.roll();
|
|
129
|
+
expect(die.toString()).toMatch(/latest=3/);
|
|
130
|
+
});
|
|
131
|
+
it("toJSON returns all histories keyed by type", () => {
|
|
132
|
+
const die = new Die(DieType.D6);
|
|
133
|
+
coreRoll.mockReturnValue(4);
|
|
134
|
+
die.roll();
|
|
135
|
+
const json = die.toJSON();
|
|
136
|
+
expect(json).toHaveProperty("normal");
|
|
137
|
+
expect(json.normal.length).toBe(1);
|
|
138
|
+
expect(json.normal[0]).toMatchObject({ roll: 4 });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Outcome } from "@platonic-dice/core/entities";
|
|
3
|
+
describe("RollRecord types (runtime shape validation)", () => {
|
|
4
|
+
const now = new Date();
|
|
5
|
+
const dieRecord = {
|
|
6
|
+
roll: 5,
|
|
7
|
+
timestamp: now,
|
|
8
|
+
};
|
|
9
|
+
const modifiedRecord = {
|
|
10
|
+
roll: 10,
|
|
11
|
+
modified: 12,
|
|
12
|
+
timestamp: now,
|
|
13
|
+
};
|
|
14
|
+
const targetRecord = {
|
|
15
|
+
roll: 17,
|
|
16
|
+
outcome: Outcome.Success,
|
|
17
|
+
timestamp: now,
|
|
18
|
+
};
|
|
19
|
+
it("should have the correct shape for DieRollRecord", () => {
|
|
20
|
+
expect(dieRecord).toHaveProperty("roll");
|
|
21
|
+
expect(dieRecord).toHaveProperty("timestamp");
|
|
22
|
+
expect(dieRecord.timestamp).toBeInstanceOf(Date);
|
|
23
|
+
expect(Object.keys(dieRecord)).toHaveLength(2);
|
|
24
|
+
});
|
|
25
|
+
it("should have the correct shape for ModifiedDieRollRecord", () => {
|
|
26
|
+
expect(modifiedRecord).toHaveProperty("roll");
|
|
27
|
+
expect(modifiedRecord).toHaveProperty("modified");
|
|
28
|
+
expect(modifiedRecord).toHaveProperty("timestamp");
|
|
29
|
+
expect(modifiedRecord.timestamp).toBeInstanceOf(Date);
|
|
30
|
+
});
|
|
31
|
+
it("should have the correct shape for TargetDieRollRecord", () => {
|
|
32
|
+
expect(targetRecord).toHaveProperty("roll");
|
|
33
|
+
expect(targetRecord).toHaveProperty("outcome");
|
|
34
|
+
expect(targetRecord).toHaveProperty("timestamp");
|
|
35
|
+
expect(targetRecord.timestamp).toBeInstanceOf(Date);
|
|
36
|
+
});
|
|
37
|
+
it("should be compatible with the RollRecord union", () => {
|
|
38
|
+
const records = [dieRecord, modifiedRecord, targetRecord];
|
|
39
|
+
expect(records).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { RollRecordManager, DEFAULT_MAX_RECORDS } from "./internal";
|
|
2
|
+
/**
|
|
3
|
+
* A wrapper for RollRecordManager that maintains multiple, independently capped histories.
|
|
4
|
+
*
|
|
5
|
+
* Useful for scenarios where a single RollRecordManager needs to support "history parking",
|
|
6
|
+
* such as storing separate roll histories per modifier or context.
|
|
7
|
+
*
|
|
8
|
+
* @template R - The type of roll records stored
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* A wrapper for RollRecordManager that maintains multiple, independently capped histories.
|
|
12
|
+
*
|
|
13
|
+
* Useful for scenarios where a single RollRecordManager needs to support "history parking",
|
|
14
|
+
* such as storing separate roll histories per modifier or context.
|
|
15
|
+
*
|
|
16
|
+
* @template R - The type of roll records stored
|
|
17
|
+
*/
|
|
18
|
+
export class HistoryCache {
|
|
19
|
+
constructor({ maxRecordsPerKey = Math.floor(DEFAULT_MAX_RECORDS / 10), maxKeys = 10, } = {}) {
|
|
20
|
+
this.cache = new Map();
|
|
21
|
+
this.maxRecordsPerKey = maxRecordsPerKey;
|
|
22
|
+
this.maxKeys = maxKeys;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Sets the currently active history key. If the key does not exist it will be created.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} key - History key to activate.
|
|
28
|
+
*/
|
|
29
|
+
setActiveKey(key) {
|
|
30
|
+
if (!this.cache.has(key)) {
|
|
31
|
+
if (this.cache.size >= this.maxKeys) {
|
|
32
|
+
const oldestKey = this.cache.keys().next().value;
|
|
33
|
+
if (!oldestKey)
|
|
34
|
+
throw new Error("Unexpected empty cache while evicting oldest key");
|
|
35
|
+
this.cache.delete(oldestKey);
|
|
36
|
+
}
|
|
37
|
+
this.cache.set(key, new RollRecordManager(this.maxRecordsPerKey));
|
|
38
|
+
}
|
|
39
|
+
this.activeKey = key;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the currently active RollRecordManager for the active key.
|
|
43
|
+
*
|
|
44
|
+
* @returns {RollRecordManager<R> | undefined} The manager for the active key (if any).
|
|
45
|
+
*/
|
|
46
|
+
get activeManager() {
|
|
47
|
+
if (!this.activeKey)
|
|
48
|
+
return undefined;
|
|
49
|
+
return this.cache.get(this.activeKey);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Adds a roll record to the active history.
|
|
53
|
+
*
|
|
54
|
+
* @param {R} record - The roll record to add.
|
|
55
|
+
* @throws {Error} If no active history key is set.
|
|
56
|
+
*/
|
|
57
|
+
add(record) {
|
|
58
|
+
const manager = this.activeManager;
|
|
59
|
+
if (!manager)
|
|
60
|
+
throw new Error("No active history key set. Call setActiveKey() first.");
|
|
61
|
+
manager.add(record);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns a copy of the roll records for the active key.
|
|
65
|
+
*
|
|
66
|
+
* @param {boolean} [verbose=false] - When true include timestamps.
|
|
67
|
+
* @returns {R[] | Omit<R, "timestamp">[]} Array of records for the active key.
|
|
68
|
+
*/
|
|
69
|
+
getAll(verbose = false) {
|
|
70
|
+
const manager = this.activeManager;
|
|
71
|
+
if (!manager)
|
|
72
|
+
return [];
|
|
73
|
+
return verbose ? manager.full : manager.all;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Clears all roll records for the active key.
|
|
77
|
+
*/
|
|
78
|
+
clearActive() {
|
|
79
|
+
const manager = this.activeManager;
|
|
80
|
+
if (manager)
|
|
81
|
+
manager.clear();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Clears all cached histories and resets the active key.
|
|
85
|
+
*/
|
|
86
|
+
clearAll() {
|
|
87
|
+
this.cache.clear();
|
|
88
|
+
this.activeKey = undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Returns a roll history report for all cached keys.
|
|
92
|
+
*
|
|
93
|
+
* @param {{limit?: number, verbose?: boolean}} [options] - Report options.
|
|
94
|
+
* @returns {Record<string, (R | Omit<R, "timestamp">)[]>} Map of key→records.
|
|
95
|
+
*/
|
|
96
|
+
report({ limit, verbose = false, } = {}) {
|
|
97
|
+
const reports = {};
|
|
98
|
+
for (const [key, manager] of this.cache.entries()) {
|
|
99
|
+
reports[key] = manager.report({ limit, verbose });
|
|
100
|
+
}
|
|
101
|
+
return reports;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns a string summary of the cache.
|
|
105
|
+
*
|
|
106
|
+
* @returns {string} Summary containing number of keys and active key.
|
|
107
|
+
*/
|
|
108
|
+
toString() {
|
|
109
|
+
return `RollHistoryCache: ${this.cache.size} keys (active: ${this.activeKey ?? "none"})`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns a JSON-friendly object mapping keys to arrays of RollRecords.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Record<string, R[]>} The cache contents as plain arrays.
|
|
115
|
+
*/
|
|
116
|
+
toJSON() {
|
|
117
|
+
const json = {};
|
|
118
|
+
for (const [key, manager] of this.cache.entries()) {
|
|
119
|
+
json[key] = manager.toJSON();
|
|
120
|
+
}
|
|
121
|
+
return json;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RollRecordManager, DEFAULT_MAX_RECORDS } from "./roll-record-manager";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./roll-record-storage";
|
package/dist/src/components/die-history/internal/roll-record-manager/internal/roll-record-storage.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory storage for roll records with a fixed capacity.
|
|
3
|
+
*
|
|
4
|
+
* This class focuses solely on storage and eviction policy. It does not
|
|
5
|
+
* perform shape validation of records (delegated to validators elsewhere).
|
|
6
|
+
*/
|
|
7
|
+
export class RollRecordStorage {
|
|
8
|
+
/**
|
|
9
|
+
* Create a new storage container.
|
|
10
|
+
* @param maxRecords Maximum number of records to retain (FIFO eviction).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Create a new storage container.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} [maxRecords=1000] Maximum number of records to retain (FIFO eviction).
|
|
16
|
+
*/
|
|
17
|
+
constructor(maxRecords = 1000) {
|
|
18
|
+
this.records = [];
|
|
19
|
+
if (typeof maxRecords !== "number" || maxRecords < 1) {
|
|
20
|
+
throw new TypeError("maxRecords must be a positive number");
|
|
21
|
+
}
|
|
22
|
+
this.maxRecords = Math.floor(maxRecords);
|
|
23
|
+
}
|
|
24
|
+
/** Number of records currently stored */
|
|
25
|
+
get size() {
|
|
26
|
+
return this.records.length;
|
|
27
|
+
}
|
|
28
|
+
/** Configured maximum number of records */
|
|
29
|
+
get maxRecordsCount() {
|
|
30
|
+
return this.maxRecords;
|
|
31
|
+
}
|
|
32
|
+
/** Returns a shallow copy of all records (timestamps preserved) */
|
|
33
|
+
get full() {
|
|
34
|
+
return [...this.records];
|
|
35
|
+
}
|
|
36
|
+
/** Add a new record and evict oldest if capacity exceeded */
|
|
37
|
+
add(record) {
|
|
38
|
+
if (!record || typeof record !== "object") {
|
|
39
|
+
throw new TypeError("Record must be an object");
|
|
40
|
+
}
|
|
41
|
+
this.records.push(record);
|
|
42
|
+
if (this.records.length > this.maxRecords) {
|
|
43
|
+
this.records.shift();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Clear all stored records */
|
|
47
|
+
clear() {
|
|
48
|
+
this.records = [];
|
|
49
|
+
}
|
|
50
|
+
/** Return the most recent N records in chronological order (oldest first) */
|
|
51
|
+
last(n = 1) {
|
|
52
|
+
if (typeof n !== "number" || n < 1) {
|
|
53
|
+
throw new TypeError("Parameter n must be a positive number");
|
|
54
|
+
}
|
|
55
|
+
const len = this.records.length;
|
|
56
|
+
if (len === 0)
|
|
57
|
+
return [];
|
|
58
|
+
const slice = this.records.slice(Math.max(len - n, 0));
|
|
59
|
+
return [...slice];
|
|
60
|
+
}
|
|
61
|
+
/** JSON-friendly copy of stored records */
|
|
62
|
+
toJSON() {
|
|
63
|
+
return this.full;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export default RollRecordStorage;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isDieRollRecord, isModifiedDieRollRecord, isTargetDieRollRecord, stripTimestamp, } from "../roll-record-validator";
|
|
2
|
+
import { RollRecordStorage } from "./internal";
|
|
3
|
+
/**
|
|
4
|
+
* Default maximum number of roll records stored.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_MAX_RECORDS = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Utility class for managing roll history of Die and child/composite classes.
|
|
9
|
+
*
|
|
10
|
+
* Provides methods to add roll records, retrieve the last N rolls,
|
|
11
|
+
* produce history reports, and manage roll record storage.
|
|
12
|
+
*
|
|
13
|
+
* @template R Type of roll record, defaults to all RollRecord variants.
|
|
14
|
+
*/
|
|
15
|
+
export class RollRecordManager {
|
|
16
|
+
/**
|
|
17
|
+
* Create a RollRecordManager.
|
|
18
|
+
*
|
|
19
|
+
* @param {number} [maxRecords=DEFAULT_MAX_RECORDS] - Maximum records to retain.
|
|
20
|
+
*/
|
|
21
|
+
constructor(maxRecords = DEFAULT_MAX_RECORDS) {
|
|
22
|
+
this.maxRecords = maxRecords;
|
|
23
|
+
this.storage = new RollRecordStorage(maxRecords);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns a copy of all roll records (including timestamps).
|
|
27
|
+
*
|
|
28
|
+
* @returns {R[]} All records (timestamps preserved).
|
|
29
|
+
*/
|
|
30
|
+
get full() {
|
|
31
|
+
return this.storage.full;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns a copy of all roll records with timestamps stripped.
|
|
35
|
+
*
|
|
36
|
+
* @returns {Omit<R, "timestamp">[]} Records without timestamps.
|
|
37
|
+
*/
|
|
38
|
+
get all() {
|
|
39
|
+
return this.storage.full.map(stripTimestamp);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the number of roll records stored.
|
|
43
|
+
*
|
|
44
|
+
* @returns {number} Number of stored records.
|
|
45
|
+
*/
|
|
46
|
+
get length() {
|
|
47
|
+
return this.storage.size;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns the configured maximum number of roll records.
|
|
51
|
+
*
|
|
52
|
+
* @returns {number} Maximum records configured.
|
|
53
|
+
*/
|
|
54
|
+
get maxRecordsCount() {
|
|
55
|
+
return this.storage.maxRecordsCount;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Adds a roll record to the history.
|
|
59
|
+
*
|
|
60
|
+
* @param {R} record - The record to add.
|
|
61
|
+
* @throws {TypeError} If the record is not a valid roll record shape.
|
|
62
|
+
*/
|
|
63
|
+
add(record) {
|
|
64
|
+
if (!record || typeof record !== "object") {
|
|
65
|
+
throw new TypeError("Record must be an object");
|
|
66
|
+
}
|
|
67
|
+
if (!isDieRollRecord(record) &&
|
|
68
|
+
!isModifiedDieRollRecord(record) &&
|
|
69
|
+
!isTargetDieRollRecord(record)) {
|
|
70
|
+
throw new TypeError("Record must be a valid DieRollRecord, ModifiedDieRollRecord, or TargetDieRollRecord");
|
|
71
|
+
}
|
|
72
|
+
this.storage.add(record);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Clears the roll history storage.
|
|
76
|
+
*/
|
|
77
|
+
clear() {
|
|
78
|
+
this.storage.clear();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Returns the last N roll records.
|
|
82
|
+
*
|
|
83
|
+
* @param {number} [n=1] - Number of records to retrieve.
|
|
84
|
+
* @param {boolean} [verbose=false] - Include timestamps when true.
|
|
85
|
+
* @returns {(R | Omit<R, "timestamp">)[]} Array of records.
|
|
86
|
+
*/
|
|
87
|
+
last(n = 1, verbose = false) {
|
|
88
|
+
if (typeof n !== "number" || n < 1) {
|
|
89
|
+
throw new TypeError("Parameter n must be a positive number.");
|
|
90
|
+
}
|
|
91
|
+
const len = this.storage.size;
|
|
92
|
+
if (len === 0)
|
|
93
|
+
return [];
|
|
94
|
+
const slice = this.storage.last(n);
|
|
95
|
+
return verbose ? slice : slice.map(stripTimestamp);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Produces a roll history report based on the given options.
|
|
99
|
+
*
|
|
100
|
+
* @param {{limit?: number; verbose?: boolean}} [options]
|
|
101
|
+
* @returns {(R | Omit<R, "timestamp">)[]} An array of records per options.
|
|
102
|
+
*/
|
|
103
|
+
report(options) {
|
|
104
|
+
const { limit, verbose = false } = options || {};
|
|
105
|
+
const n = Math.min(limit ?? this.storage.size, this.storage.size);
|
|
106
|
+
return n === 0 ? [] : this.last(n, verbose);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Human-readable string summary of roll history.
|
|
110
|
+
*
|
|
111
|
+
* @returns {string} Summary that contains last roll and counts.
|
|
112
|
+
*/
|
|
113
|
+
toString() {
|
|
114
|
+
if (this.storage.size === 0) {
|
|
115
|
+
return `RollRecordManager: empty (maxRecords=${this.maxRecords})`;
|
|
116
|
+
}
|
|
117
|
+
const lastRecord = this.storage.full[this.storage.size - 1];
|
|
118
|
+
return `RollRecordManager: ${this.storage.size}/${this.maxRecords} rolls (last: ${lastRecord.roll} @ ${lastRecord.timestamp.toISOString()})`;
|
|
119
|
+
}
|
|
120
|
+
/** Returns the full history as an array of records */
|
|
121
|
+
toJSON() {
|
|
122
|
+
return this.storage.toJSON();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Outcome } from "@platonic-dice/core";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime type-guards for RollRecord shapes.
|
|
4
|
+
* Kept intentionally small and side-effect free.
|
|
5
|
+
*/
|
|
6
|
+
export function isDieRollRecord(record) {
|
|
7
|
+
return (!!record &&
|
|
8
|
+
typeof record.roll === "number" &&
|
|
9
|
+
record.timestamp instanceof Date &&
|
|
10
|
+
!("modified" in record) &&
|
|
11
|
+
!("outcome" in record));
|
|
12
|
+
}
|
|
13
|
+
export function isModifiedDieRollRecord(record) {
|
|
14
|
+
return (!!record &&
|
|
15
|
+
typeof record.roll === "number" &&
|
|
16
|
+
typeof record.modified === "number" &&
|
|
17
|
+
record.timestamp instanceof Date &&
|
|
18
|
+
!("outcome" in record));
|
|
19
|
+
}
|
|
20
|
+
export function isTargetDieRollRecord(record) {
|
|
21
|
+
return (!!record &&
|
|
22
|
+
typeof record.roll === "number" &&
|
|
23
|
+
"outcome" in record &&
|
|
24
|
+
Object.values(Outcome).includes(record.outcome) &&
|
|
25
|
+
record.timestamp instanceof Date);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Remove timestamp property from a record (immutable-friendly).
|
|
29
|
+
*
|
|
30
|
+
* @template R
|
|
31
|
+
* @param {R} record - The record to strip timestamp from.
|
|
32
|
+
* @returns {Omit<R, "timestamp">} The record without the timestamp property.
|
|
33
|
+
*/
|
|
34
|
+
export function stripTimestamp(record) {
|
|
35
|
+
const { timestamp, ...rest } = record;
|
|
36
|
+
return rest;
|
|
37
|
+
}
|
|
38
|
+
export default {
|
|
39
|
+
isDieRollRecord,
|
|
40
|
+
isModifiedDieRollRecord,
|
|
41
|
+
isTargetDieRollRecord,
|
|
42
|
+
stripTimestamp,
|
|
43
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { RollType, roll as coreRoll, rollMod as coreRollMod, rollTest as coreRollTest, } from "@platonic-dice/core";
|
|
2
|
+
import { isDieRollRecord, isModifiedDieRollRecord, isTargetDieRollRecord, } from "./internal";
|
|
3
|
+
/**
|
|
4
|
+
* Default implementation that delegates to `@platonic-dice/core` and uses
|
|
5
|
+
* the system clock. This keeps the public API simple and avoids DI.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Implementation of RollRecordFactory that delegates to @platonic-dice/core and uses the system clock.
|
|
9
|
+
* This keeps the public API simple and avoids DI.
|
|
10
|
+
*/
|
|
11
|
+
export class RollRecordFactory {
|
|
12
|
+
createNormalRoll(dieType, rollType) {
|
|
13
|
+
// Runtime validation without `any` casts: compare against the known
|
|
14
|
+
// RollType members (explicit checks keep types narrow and avoid casts).
|
|
15
|
+
if (rollType !== undefined &&
|
|
16
|
+
rollType !== RollType.Advantage &&
|
|
17
|
+
rollType !== RollType.Disadvantage) {
|
|
18
|
+
throw new TypeError(`Invalid rollType: ${String(rollType)}`);
|
|
19
|
+
}
|
|
20
|
+
// Delegate to core roll implementation and attach a timestamp.
|
|
21
|
+
const value = coreRoll(dieType, rollType);
|
|
22
|
+
const ts = new Date();
|
|
23
|
+
const record = { roll: value, timestamp: ts };
|
|
24
|
+
if (!isDieRollRecord(record)) {
|
|
25
|
+
throw new Error("Factory produced invalid DieRollRecord");
|
|
26
|
+
}
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
createModifiedRoll(dieType, modifier, rollType) {
|
|
30
|
+
// Resolve base and modified values using the core helper, then stamp.
|
|
31
|
+
const { base, modified } = coreRollMod(dieType, modifier, rollType);
|
|
32
|
+
const ts = new Date();
|
|
33
|
+
const record = {
|
|
34
|
+
roll: base,
|
|
35
|
+
modified,
|
|
36
|
+
timestamp: ts,
|
|
37
|
+
};
|
|
38
|
+
if (!isModifiedDieRollRecord(record)) {
|
|
39
|
+
throw new Error("Factory produced invalid ModifiedDieRollRecord");
|
|
40
|
+
}
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
createTestRoll(dieType, testConditions, rollType) {
|
|
44
|
+
// Core normalises and evaluates test conditions; we retain the outcome
|
|
45
|
+
// and attach a timestamp to produce a TestDieRollRecord.
|
|
46
|
+
const { base, outcome } = coreRollTest(dieType, testConditions, rollType);
|
|
47
|
+
const ts = new Date();
|
|
48
|
+
const record = { roll: base, outcome, timestamp: ts };
|
|
49
|
+
if (!isTargetDieRollRecord(record)) {
|
|
50
|
+
throw new Error("Factory produced invalid TestDieRollRecord");
|
|
51
|
+
}
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export default RollRecordFactory;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./die-history";
|
package/dist/src/die.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { DieType, RollType } from "@platonic-dice/core";
|
|
2
|
+
import { RollRecordFactory, HistoryCache } from "@dice/components";
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single die with flexible history tracking.
|
|
5
|
+
*
|
|
6
|
+
* The Die class provides:
|
|
7
|
+
* - Normal rolls (numeric)
|
|
8
|
+
* - Modified rolls (numeric or functional modifiers)
|
|
9
|
+
* - Test rolls (success/failure evaluation)
|
|
10
|
+
*
|
|
11
|
+
* Each roll type is stored independently in a `RollHistoryCache`.
|
|
12
|
+
*
|
|
13
|
+
* Example:
|
|
14
|
+
* ```ts
|
|
15
|
+
* const d20 = new Die(DieType.D20);
|
|
16
|
+
* const result = d20.roll(); // normal roll
|
|
17
|
+
* const modResult = d20.rollMod(n => n + 2); // modified roll
|
|
18
|
+
* const testResult = d20.rollTest({ testType: "AtLeast", target: 15 });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export class Die {
|
|
22
|
+
/**
|
|
23
|
+
* Create a new Die instance.
|
|
24
|
+
* @param type - The die type (must be a value from `DieType`)
|
|
25
|
+
* @param historyCache - Optional custom `RollHistoryCache` instance
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Create a new Die instance.
|
|
29
|
+
*
|
|
30
|
+
* @param {DieTypeValue} type - The die type (must be a value from `DieType`).
|
|
31
|
+
* @param {HistoryCache<RollRecord>} [historyCache] - Optional custom history cache instance.
|
|
32
|
+
* @throws {Error} If `type` is not a valid `DieType` value.
|
|
33
|
+
*/
|
|
34
|
+
constructor(type, historyCache) {
|
|
35
|
+
this.recordFactory = new RollRecordFactory();
|
|
36
|
+
// Accept only the narrow literal union type (DieTypeValue) for strictness.
|
|
37
|
+
// Runtime validation still guards against invalid values.
|
|
38
|
+
if (!Object.values(DieType).includes(type)) {
|
|
39
|
+
throw new Error(`Invalid die type: ${type}`);
|
|
40
|
+
}
|
|
41
|
+
this.typeValue = type;
|
|
42
|
+
this.rolls = historyCache ?? new HistoryCache({ maxKeys: 10 });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Notes on behavior and contracts
|
|
46
|
+
* - `resultValue` holds the most recent numeric value produced by a roll
|
|
47
|
+
* operation. It is updated from factory-produced records: for normal and
|
|
48
|
+
* test rolls it tracks `record.roll`; for modified rolls it tracks
|
|
49
|
+
* `record.modified`.
|
|
50
|
+
* - `rolls` is a `HistoryCache` that stores separate histories keyed by
|
|
51
|
+
* roll type (normal/modifier/test). Each public roll method sets the
|
|
52
|
+
* corresponding active key, creates a factory-produced record and adds
|
|
53
|
+
* it to the active history. Record shape validation happens at the
|
|
54
|
+
* `RollRecordManager.add()` boundary.
|
|
55
|
+
*/
|
|
56
|
+
/** The die type (e.g., `d6`, `d20`) */
|
|
57
|
+
get type() {
|
|
58
|
+
return this.typeValue;
|
|
59
|
+
}
|
|
60
|
+
/** The most recent numeric roll result, or undefined if not rolled yet */
|
|
61
|
+
get result() {
|
|
62
|
+
return this.resultValue;
|
|
63
|
+
}
|
|
64
|
+
/** Number of faces on this die */
|
|
65
|
+
get faceCount() {
|
|
66
|
+
const lookup = {
|
|
67
|
+
[DieType.D4]: 4,
|
|
68
|
+
[DieType.D6]: 6,
|
|
69
|
+
[DieType.D8]: 8,
|
|
70
|
+
[DieType.D10]: 10,
|
|
71
|
+
[DieType.D12]: 12,
|
|
72
|
+
[DieType.D20]: 20,
|
|
73
|
+
};
|
|
74
|
+
return lookup[this.typeValue];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reset the most recent result.
|
|
78
|
+
* @param complete - If true, clears all histories for all roll types
|
|
79
|
+
*/
|
|
80
|
+
reset(complete = false) {
|
|
81
|
+
this.resultValue = undefined;
|
|
82
|
+
if (complete)
|
|
83
|
+
this.rolls.clearAll();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Perform a normal die roll.
|
|
87
|
+
* @param rollType - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`)
|
|
88
|
+
* @returns The numeric result
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Perform a normal die roll.
|
|
92
|
+
*
|
|
93
|
+
* @param {RollTypeValue} [rollType] - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`).
|
|
94
|
+
* @returns {number} The numeric result of the roll.
|
|
95
|
+
* @throws {Error} If `rollType` is provided but invalid.
|
|
96
|
+
*/
|
|
97
|
+
roll(rollType) {
|
|
98
|
+
if (rollType !== undefined &&
|
|
99
|
+
!Object.values(RollType).includes(rollType)) {
|
|
100
|
+
throw new Error(`Invalid roll type: ${rollType}`);
|
|
101
|
+
}
|
|
102
|
+
// Delegate record creation to the RollRecordFactory to centralize shape
|
|
103
|
+
const record = this.recordFactory.createNormalRoll(this.typeValue, rollType);
|
|
104
|
+
this.resultValue = record.roll;
|
|
105
|
+
this.rolls.setActiveKey(Die.NORMAL_KEY);
|
|
106
|
+
this.rolls.add(record);
|
|
107
|
+
return record.roll;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Perform a roll with a modifier.
|
|
111
|
+
*
|
|
112
|
+
* @param modifier - Numeric or functional modifier (function `(n: number) => number` or `RollModifierInstance`)
|
|
113
|
+
* @param rollType - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`)
|
|
114
|
+
* @returns The modified numeric result
|
|
115
|
+
*
|
|
116
|
+
* Example:
|
|
117
|
+
* ```ts
|
|
118
|
+
* d20.rollMod(n => n + 2); // adds +2 to the roll
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Perform a roll with a modifier.
|
|
123
|
+
*
|
|
124
|
+
* @param {RollModifierFunction | RollModifierInstance} modifier - Numeric or functional modifier applied to the base roll.
|
|
125
|
+
* @param {RollTypeValue} [rollType] - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`).
|
|
126
|
+
* @returns {number} The modified numeric result.
|
|
127
|
+
* @example
|
|
128
|
+
* d20.rollMod(n => n + 2); // adds +2 to the roll
|
|
129
|
+
*/
|
|
130
|
+
rollMod(modifier, rollType) {
|
|
131
|
+
const record = this.recordFactory.createModifiedRoll(this.typeValue, modifier, rollType);
|
|
132
|
+
this.resultValue = record.modified;
|
|
133
|
+
this.rolls.setActiveKey(Die.MODIFIER_KEY);
|
|
134
|
+
this.rolls.add(record);
|
|
135
|
+
return record.modified;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Perform a roll against test conditions (success/failure evaluation).
|
|
139
|
+
*
|
|
140
|
+
* @param testConditions - Test conditions (plain object or `TestConditionsInstance`)
|
|
141
|
+
* @param rollType - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`)
|
|
142
|
+
* @returns The base numeric roll
|
|
143
|
+
*
|
|
144
|
+
* Example:
|
|
145
|
+
* ```ts
|
|
146
|
+
* d20.rollTest({ testType: "AtLeast", target: 15 });
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* Perform a roll against test conditions (success/failure evaluation).
|
|
151
|
+
*
|
|
152
|
+
* @param {TestConditionsInstance | { testType: TestTypeValue; [k: string]: any }} testConditions - Test conditions (plain object or normalized `TestConditions` instance).
|
|
153
|
+
* @param {RollTypeValue} [rollType] - Optional roll mode (`RollType.Advantage` / `RollType.Disadvantage`).
|
|
154
|
+
* @returns {number} The base numeric roll used to evaluate the test.
|
|
155
|
+
* @throws {Error} If `testConditions` or `rollType` are invalid (delegated to core).
|
|
156
|
+
* @example
|
|
157
|
+
* d20.rollTest({ testType: "at_least", target: 15 });
|
|
158
|
+
*/
|
|
159
|
+
rollTest(testConditions, rollType) {
|
|
160
|
+
const record = this.recordFactory.createTestRoll(this.typeValue, testConditions, rollType);
|
|
161
|
+
this.resultValue = record.roll;
|
|
162
|
+
this.rolls.setActiveKey(Die.TEST_KEY);
|
|
163
|
+
this.rolls.add(record);
|
|
164
|
+
return record.roll;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Retrieve roll history for a given key.
|
|
168
|
+
*
|
|
169
|
+
* @param key - `"normal" | "modifier" | "test"`
|
|
170
|
+
* @param verbose - Include timestamps if true
|
|
171
|
+
* @returns Array of roll records (timestamps included if verbose)
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Retrieve roll history for a given key.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} [key=Die.NORMAL_KEY] - One of "normal" | "modifier" | "test".
|
|
177
|
+
* @param {boolean} [verbose=false] - Include timestamps when true.
|
|
178
|
+
* @returns {Array} Array of roll records (timestamps included when verbose).
|
|
179
|
+
*/
|
|
180
|
+
history(key = Die.NORMAL_KEY, verbose = false) {
|
|
181
|
+
this.rolls.setActiveKey(key);
|
|
182
|
+
return this.rolls.getAll(verbose);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Retrieve a roll report for a specific key.
|
|
186
|
+
*
|
|
187
|
+
* @param key - `"normal" | "modifier" | "test"`
|
|
188
|
+
* @param options - Optional report options (`limit` and `verbose`)
|
|
189
|
+
* @returns Array of roll records (subject to limit and verbose)
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* Retrieve a roll report for a specific key.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} [key=Die.NORMAL_KEY] - History key to report on.
|
|
195
|
+
* @param {{limit?: number, verbose?: boolean}} [options] - Report options.
|
|
196
|
+
* @returns {Array} Array of roll records (subject to `limit` and `verbose`).
|
|
197
|
+
*/
|
|
198
|
+
report(key = Die.NORMAL_KEY, options) {
|
|
199
|
+
this.rolls.setActiveKey(key);
|
|
200
|
+
return this.rolls.activeManager?.report(options) ?? [];
|
|
201
|
+
}
|
|
202
|
+
/** Human-readable summary of the die */
|
|
203
|
+
/**
|
|
204
|
+
* Human-readable summary of the die
|
|
205
|
+
*
|
|
206
|
+
* @returns {string} Short description including last result if present.
|
|
207
|
+
*/
|
|
208
|
+
toString() {
|
|
209
|
+
if (this.resultValue === undefined)
|
|
210
|
+
return `Die(${this.typeValue}): not rolled yet`;
|
|
211
|
+
return `Die(${this.typeValue}): latest=${this.resultValue}`;
|
|
212
|
+
}
|
|
213
|
+
/** JSON representation of all histories keyed by roll type */
|
|
214
|
+
/**
|
|
215
|
+
* JSON representation of all histories keyed by roll type
|
|
216
|
+
*
|
|
217
|
+
* @returns {Record<string, RollRecord[]>} Mapping of history keys to arrays of records.
|
|
218
|
+
*/
|
|
219
|
+
toJSON() {
|
|
220
|
+
return this.rolls.toJSON();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Keys used internally for history separation */
|
|
224
|
+
Die.NORMAL_KEY = "normal";
|
|
225
|
+
Die.MODIFIER_KEY = "modifier";
|
|
226
|
+
Die.TEST_KEY = "test";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./roll-record.types";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@platonic-dice/dice",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Persistent dice objects with roll history and TypeScript support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dice",
|
|
13
|
+
"TTRPG",
|
|
14
|
+
"random",
|
|
15
|
+
"game",
|
|
16
|
+
"d20",
|
|
17
|
+
"dnd"
|
|
18
|
+
],
|
|
19
|
+
"author": "Stephen James Saunders",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/sjs2k20/platonic-dice.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/sjs2k20/platonic-dice.git/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/sjs2k20/platonic-dice.git#readme",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"prepublishOnly": "npm run build",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@platonic-dice/core": "^2.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
40
|
+
"vitest": "^4.0.9"
|
|
41
|
+
}
|
|
42
|
+
}
|