run-dmcp 0.1.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 +22 -0
- package/README.md +79 -0
- package/dist/__tests__/engineVocabulary.test.d.ts +1 -0
- package/dist/__tests__/engineVocabulary.test.js +147 -0
- package/dist/db/__tests__/connection.test.d.ts +1 -0
- package/dist/db/__tests__/connection.test.js +72 -0
- package/dist/db/__tests__/testDb.d.ts +33 -0
- package/dist/db/__tests__/testDb.js +41 -0
- package/dist/db/connection.d.ts +22 -0
- package/dist/db/connection.js +107 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +725 -0
- package/dist/events/emitter.d.ts +22 -0
- package/dist/events/emitter.js +71 -0
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +649 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +92 -0
- package/dist/register/abilities.d.ts +2 -0
- package/dist/register/abilities.js +165 -0
- package/dist/register/audio.d.ts +2 -0
- package/dist/register/audio.js +326 -0
- package/dist/register/batch.d.ts +2 -0
- package/dist/register/batch.js +343 -0
- package/dist/register/character.d.ts +2 -0
- package/dist/register/character.js +324 -0
- package/dist/register/combat.d.ts +2 -0
- package/dist/register/combat.js +207 -0
- package/dist/register/core.d.ts +2 -0
- package/dist/register/core.js +1040 -0
- package/dist/register/display.d.ts +2 -0
- package/dist/register/display.js +263 -0
- package/dist/register/factions.d.ts +2 -0
- package/dist/register/factions.js +186 -0
- package/dist/register/images.d.ts +2 -0
- package/dist/register/images.js +400 -0
- package/dist/register/inventory.d.ts +2 -0
- package/dist/register/inventory.js +115 -0
- package/dist/register/mcp-prompts.d.ts +2 -0
- package/dist/register/mcp-prompts.js +684 -0
- package/dist/register/mcp-resources.d.ts +2 -0
- package/dist/register/mcp-resources.js +335 -0
- package/dist/register/narrative.d.ts +2 -0
- package/dist/register/narrative.js +242 -0
- package/dist/register/notes.d.ts +2 -0
- package/dist/register/notes.js +170 -0
- package/dist/register/pause.d.ts +2 -0
- package/dist/register/pause.js +580 -0
- package/dist/register/quests.d.ts +2 -0
- package/dist/register/quests.js +118 -0
- package/dist/register/relationships.d.ts +2 -0
- package/dist/register/relationships.js +147 -0
- package/dist/register/resources.d.ts +2 -0
- package/dist/register/resources.js +277 -0
- package/dist/register/secrets.d.ts +2 -0
- package/dist/register/secrets.js +192 -0
- package/dist/register/status.d.ts +2 -0
- package/dist/register/status.js +130 -0
- package/dist/register/tables.d.ts +2 -0
- package/dist/register/tables.js +146 -0
- package/dist/register/tags.d.ts +2 -0
- package/dist/register/tags.js +114 -0
- package/dist/register/time.d.ts +2 -0
- package/dist/register/time.js +281 -0
- package/dist/register/world.d.ts +2 -0
- package/dist/register/world.js +127 -0
- package/dist/schemas/index.d.ts +921 -0
- package/dist/schemas/index.js +121 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +13 -0
- package/dist/tools/__tests__/audio.test.d.ts +1 -0
- package/dist/tools/__tests__/audio.test.js +59 -0
- package/dist/tools/__tests__/conserved.test.d.ts +1 -0
- package/dist/tools/__tests__/conserved.test.js +488 -0
- package/dist/tools/__tests__/constraint.test.d.ts +1 -0
- package/dist/tools/__tests__/constraint.test.js +212 -0
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +1 -0
- package/dist/tools/__tests__/expiry-consequences.test.js +110 -0
- package/dist/tools/__tests__/images.test.d.ts +1 -0
- package/dist/tools/__tests__/images.test.js +59 -0
- package/dist/tools/__tests__/relationship.test.d.ts +1 -0
- package/dist/tools/__tests__/relationship.test.js +132 -0
- package/dist/tools/__tests__/resource-constraints.test.d.ts +1 -0
- package/dist/tools/__tests__/resource-constraints.test.js +131 -0
- package/dist/tools/__tests__/resource.test.d.ts +1 -0
- package/dist/tools/__tests__/resource.test.js +190 -0
- package/dist/tools/__tests__/time.test.d.ts +1 -0
- package/dist/tools/__tests__/time.test.js +404 -0
- package/dist/tools/__tests__/timers.test.d.ts +1 -0
- package/dist/tools/__tests__/timers.test.js +426 -0
- package/dist/tools/__tests__/world.test.d.ts +1 -0
- package/dist/tools/__tests__/world.test.js +70 -0
- package/dist/tools/ability.d.ts +48 -0
- package/dist/tools/ability.js +238 -0
- package/dist/tools/audio.d.ts +24 -0
- package/dist/tools/audio.js +365 -0
- package/dist/tools/character.d.ts +70 -0
- package/dist/tools/character.js +309 -0
- package/dist/tools/combat.d.ts +13 -0
- package/dist/tools/combat.js +195 -0
- package/dist/tools/constraint.d.ts +132 -0
- package/dist/tools/constraint.js +269 -0
- package/dist/tools/dice.d.ts +23 -0
- package/dist/tools/dice.js +111 -0
- package/dist/tools/display.d.ts +120 -0
- package/dist/tools/display.js +528 -0
- package/dist/tools/faction.d.ts +61 -0
- package/dist/tools/faction.js +269 -0
- package/dist/tools/game.d.ts +96 -0
- package/dist/tools/game.js +526 -0
- package/dist/tools/image-prompt.d.ts +49 -0
- package/dist/tools/image-prompt.js +479 -0
- package/dist/tools/images.d.ts +47 -0
- package/dist/tools/images.js +449 -0
- package/dist/tools/inventory.d.ts +20 -0
- package/dist/tools/inventory.js +145 -0
- package/dist/tools/narrative.d.ts +58 -0
- package/dist/tools/narrative.js +237 -0
- package/dist/tools/notes.d.ts +41 -0
- package/dist/tools/notes.js +220 -0
- package/dist/tools/pause.d.ts +110 -0
- package/dist/tools/pause.js +1254 -0
- package/dist/tools/quest.d.ts +34 -0
- package/dist/tools/quest.js +164 -0
- package/dist/tools/relationship.d.ts +74 -0
- package/dist/tools/relationship.js +324 -0
- package/dist/tools/resource.d.ts +93 -0
- package/dist/tools/resource.js +374 -0
- package/dist/tools/rules.d.ts +4 -0
- package/dist/tools/rules.js +30 -0
- package/dist/tools/secrets.d.ts +49 -0
- package/dist/tools/secrets.js +195 -0
- package/dist/tools/status.d.ts +36 -0
- package/dist/tools/status.js +218 -0
- package/dist/tools/tables.d.ts +33 -0
- package/dist/tools/tables.js +209 -0
- package/dist/tools/tags.d.ts +52 -0
- package/dist/tools/tags.js +176 -0
- package/dist/tools/time.d.ts +33 -0
- package/dist/tools/time.js +276 -0
- package/dist/tools/timers.d.ts +41 -0
- package/dist/tools/timers.js +215 -0
- package/dist/tools/world.d.ts +78 -0
- package/dist/tools/world.js +331 -0
- package/dist/types/index.d.ts +969 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/__tests__/json.test.d.ts +1 -0
- package/dist/utils/__tests__/json.test.js +55 -0
- package/dist/utils/__tests__/validation.test.d.ts +1 -0
- package/dist/utils/__tests__/validation.test.js +90 -0
- package/dist/utils/errors.d.ts +44 -0
- package/dist/utils/errors.js +121 -0
- package/dist/utils/json.d.ts +9 -0
- package/dist/utils/json.js +23 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +50 -0
- package/dist/utils/output-schemas.d.ts +594 -0
- package/dist/utils/output-schemas.js +331 -0
- package/dist/utils/tool-annotations.d.ts +147 -0
- package/dist/utils/tool-annotations.js +98 -0
- package/dist/utils/validation.d.ts +34 -0
- package/dist/utils/validation.js +52 -0
- package/dist/utils/verbosity.d.ts +57 -0
- package/dist/utils/verbosity.js +67 -0
- package/dist/utils/webui.d.ts +20 -0
- package/dist/utils/webui.js +35 -0
- package/package.json +75 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createTestDb, destroyTestDb } from "../../db/__tests__/testDb.js";
|
|
3
|
+
import { createGame } from "../game.js";
|
|
4
|
+
import { createResource } from "../resource.js";
|
|
5
|
+
import { declareBoundedConstraint, declareMonotonicConstraint, declareConservedConstraint, listConstraints, getConstraintsForResource, removeConstraint, } from "../constraint.js";
|
|
6
|
+
describe("resource constraint registry", () => {
|
|
7
|
+
let gameId;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
createTestDb();
|
|
10
|
+
gameId = createGame({ name: "Test Game", setting: "test", style: "test" }).id;
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
destroyTestDb();
|
|
14
|
+
});
|
|
15
|
+
describe("bounded", () => {
|
|
16
|
+
it("declares a bounded constraint on a resource that has min/max set", () => {
|
|
17
|
+
const resource = createResource({
|
|
18
|
+
gameId,
|
|
19
|
+
ownerType: "game",
|
|
20
|
+
name: "grain",
|
|
21
|
+
value: 50,
|
|
22
|
+
minValue: 0,
|
|
23
|
+
maxValue: 100,
|
|
24
|
+
});
|
|
25
|
+
const constraint = declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
26
|
+
expect(constraint.kind).toBe("bounded");
|
|
27
|
+
expect(constraint.gameId).toBe(gameId);
|
|
28
|
+
expect(constraint.resourceIds).toEqual([resource.id]);
|
|
29
|
+
expect(constraint.id).toBeTruthy();
|
|
30
|
+
expect(constraint.createdAt).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
it("rejects declaring a bounded constraint on a resource with no bounds", () => {
|
|
33
|
+
const resource = createResource({ gameId, ownerType: "game", name: "grain", value: 50 });
|
|
34
|
+
expect(() => declareBoundedConstraint({ gameId, resourceId: resource.id })).toThrow();
|
|
35
|
+
});
|
|
36
|
+
it("rejects declaring a second bounded constraint on the same resource", () => {
|
|
37
|
+
const resource = createResource({
|
|
38
|
+
gameId,
|
|
39
|
+
ownerType: "game",
|
|
40
|
+
name: "grain",
|
|
41
|
+
value: 50,
|
|
42
|
+
minValue: 0,
|
|
43
|
+
maxValue: 100,
|
|
44
|
+
});
|
|
45
|
+
declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
46
|
+
expect(() => declareBoundedConstraint({ gameId, resourceId: resource.id })).toThrow();
|
|
47
|
+
});
|
|
48
|
+
it("rejects declaring a constraint on a nonexistent resource", () => {
|
|
49
|
+
expect(() => declareBoundedConstraint({ gameId, resourceId: "does-not-exist" })).toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("monotonic", () => {
|
|
53
|
+
it("declares a never-decreasing (increasing) constraint", () => {
|
|
54
|
+
const resource = createResource({ gameId, ownerType: "game", name: "population", value: 10 });
|
|
55
|
+
const constraint = declareMonotonicConstraint({
|
|
56
|
+
gameId,
|
|
57
|
+
resourceId: resource.id,
|
|
58
|
+
direction: "increasing",
|
|
59
|
+
});
|
|
60
|
+
expect(constraint.kind).toBe("monotonic");
|
|
61
|
+
expect(constraint.direction).toBe("increasing");
|
|
62
|
+
expect(constraint.resourceIds).toEqual([resource.id]);
|
|
63
|
+
});
|
|
64
|
+
it("declares a never-increasing (decreasing) constraint", () => {
|
|
65
|
+
const resource = createResource({ gameId, ownerType: "game", name: "treasury_debt", value: 10 });
|
|
66
|
+
const constraint = declareMonotonicConstraint({
|
|
67
|
+
gameId,
|
|
68
|
+
resourceId: resource.id,
|
|
69
|
+
direction: "decreasing",
|
|
70
|
+
});
|
|
71
|
+
expect(constraint.direction).toBe("decreasing");
|
|
72
|
+
});
|
|
73
|
+
it("rejects a second monotonic constraint on the same resource", () => {
|
|
74
|
+
const resource = createResource({ gameId, ownerType: "game", name: "population", value: 10 });
|
|
75
|
+
declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "increasing" });
|
|
76
|
+
expect(() => declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "decreasing" })).toThrow();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// Full enforcement (transfer_resource_value, the ambiguous-single-write
|
|
80
|
+
// rejection, delete/update guards, atomicity, float drift, etc.) is
|
|
81
|
+
// covered exhaustively in conserved.test.ts. This block covers only
|
|
82
|
+
// declare-time validation of the registry entry itself.
|
|
83
|
+
describe("conserved: declaration validation", () => {
|
|
84
|
+
it("registers a conserved constraint across a set of resources whose values already sum to total", () => {
|
|
85
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
86
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
87
|
+
const constraint = declareConservedConstraint({
|
|
88
|
+
gameId,
|
|
89
|
+
resourceIds: [a.id, b.id],
|
|
90
|
+
total: 100,
|
|
91
|
+
});
|
|
92
|
+
expect(constraint.kind).toBe("conserved");
|
|
93
|
+
expect(constraint.resourceIds.sort()).toEqual([a.id, b.id].sort());
|
|
94
|
+
expect(constraint.total).toBe(100);
|
|
95
|
+
expect(constraint.direction).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
it("registers a conserved constraint across 3+ resources", () => {
|
|
98
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 20 });
|
|
99
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 30 });
|
|
100
|
+
const c = createResource({ gameId, ownerType: "game", name: "seed_grain", value: 50 });
|
|
101
|
+
const constraint = declareConservedConstraint({
|
|
102
|
+
gameId,
|
|
103
|
+
resourceIds: [a.id, b.id, c.id],
|
|
104
|
+
total: 100,
|
|
105
|
+
});
|
|
106
|
+
expect(constraint.resourceIds.sort()).toEqual([a.id, b.id, c.id].sort());
|
|
107
|
+
});
|
|
108
|
+
it("rejects a conserved constraint with fewer than two resources", () => {
|
|
109
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
110
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id], total: 40 })).toThrow();
|
|
111
|
+
});
|
|
112
|
+
it("rejects a conserved constraint with an empty resource list", () => {
|
|
113
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [], total: 0 })).toThrow();
|
|
114
|
+
});
|
|
115
|
+
it("rejects when a listed resource does not exist", () => {
|
|
116
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
117
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, "does-not-exist"], total: 40 })).toThrow();
|
|
118
|
+
});
|
|
119
|
+
it("rejects when the game does not exist", () => {
|
|
120
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
121
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
122
|
+
expect(() => declareConservedConstraint({ gameId: "does-not-exist", resourceIds: [a.id, b.id], total: 100 })).toThrow();
|
|
123
|
+
});
|
|
124
|
+
it("rejects a declaration whose members' current values do not already sum to the declared total", () => {
|
|
125
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
126
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
127
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 999 })).toThrow(/sum/i);
|
|
128
|
+
// Must NOT have silently "fixed" either resource's value to make it true.
|
|
129
|
+
expect(a.value).toBe(40);
|
|
130
|
+
expect(b.value).toBe(60);
|
|
131
|
+
});
|
|
132
|
+
it("does not silently adjust resource values when the declared total is wrong", () => {
|
|
133
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
134
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
135
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 50 })).toThrow();
|
|
136
|
+
expect(getConstraintsForResource(a.id)).toEqual([]);
|
|
137
|
+
expect(getConstraintsForResource(b.id)).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
it("rejects a non-finite total (NaN)", () => {
|
|
140
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
141
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
142
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: NaN })).toThrow();
|
|
143
|
+
});
|
|
144
|
+
it("rejects a non-finite total (Infinity)", () => {
|
|
145
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
146
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
147
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: Infinity })).toThrow();
|
|
148
|
+
});
|
|
149
|
+
it("dedupes a repeated resourceId in the input and validates the sum against the unique set", () => {
|
|
150
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
151
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
152
|
+
const constraint = declareConservedConstraint({
|
|
153
|
+
gameId,
|
|
154
|
+
resourceIds: [a.id, b.id, a.id],
|
|
155
|
+
total: 100,
|
|
156
|
+
});
|
|
157
|
+
expect(constraint.resourceIds.sort()).toEqual([a.id, b.id].sort());
|
|
158
|
+
});
|
|
159
|
+
it("rejects declaring a second conserved constraint that overlaps membership with an existing one", () => {
|
|
160
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
161
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
162
|
+
const c = createResource({ gameId, ownerType: "game", name: "seed_grain", value: 10 });
|
|
163
|
+
declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
|
|
164
|
+
// 'a' is already a member of a conserved set -- a second, overlapping
|
|
165
|
+
// conserved set would make it ambiguous which set a transfer touching
|
|
166
|
+
// 'a' is supposed to preserve.
|
|
167
|
+
expect(() => declareConservedConstraint({ gameId, resourceIds: [a.id, c.id], total: 50 })).toThrow(/already belongs to a 'conserved' constraint/i);
|
|
168
|
+
});
|
|
169
|
+
it("allows a resource with a 'bounded' constraint to also join a 'conserved' set", () => {
|
|
170
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40, minValue: 0, maxValue: 100 });
|
|
171
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
172
|
+
declareBoundedConstraint({ gameId, resourceId: a.id });
|
|
173
|
+
const constraint = declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
|
|
174
|
+
expect(constraint.kind).toBe("conserved");
|
|
175
|
+
const forA = getConstraintsForResource(a.id).map((c) => c.kind).sort();
|
|
176
|
+
expect(forA).toEqual(["bounded", "conserved"]);
|
|
177
|
+
});
|
|
178
|
+
it("allows a resource with a 'monotonic' constraint to also join a 'conserved' set", () => {
|
|
179
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
180
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
181
|
+
declareMonotonicConstraint({ gameId, resourceId: a.id, direction: "increasing" });
|
|
182
|
+
const constraint = declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
|
|
183
|
+
expect(constraint.kind).toBe("conserved");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("listConstraints / getConstraintsForResource / removeConstraint", () => {
|
|
187
|
+
it("lists constraints for a game, optionally filtered by resource", () => {
|
|
188
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 50, minValue: 0, maxValue: 100 });
|
|
189
|
+
const b = createResource({ gameId, ownerType: "game", name: "population", value: 10 });
|
|
190
|
+
const c1 = declareBoundedConstraint({ gameId, resourceId: a.id });
|
|
191
|
+
const c2 = declareMonotonicConstraint({ gameId, resourceId: b.id, direction: "increasing" });
|
|
192
|
+
const all = listConstraints(gameId);
|
|
193
|
+
expect(all.map((c) => c.id).sort()).toEqual([c1.id, c2.id].sort());
|
|
194
|
+
const forA = listConstraints(gameId, a.id);
|
|
195
|
+
expect(forA.map((c) => c.id)).toEqual([c1.id]);
|
|
196
|
+
});
|
|
197
|
+
it("getConstraintsForResource finds constraints where the resource is a member, including conserved sets", () => {
|
|
198
|
+
const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
|
|
199
|
+
const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
|
|
200
|
+
const conserved = declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
|
|
201
|
+
expect(getConstraintsForResource(a.id).map((c) => c.id)).toEqual([conserved.id]);
|
|
202
|
+
expect(getConstraintsForResource(b.id).map((c) => c.id)).toEqual([conserved.id]);
|
|
203
|
+
});
|
|
204
|
+
it("removes a constraint by id", () => {
|
|
205
|
+
const resource = createResource({ gameId, ownerType: "game", name: "grain", value: 50, minValue: 0, maxValue: 100 });
|
|
206
|
+
const constraint = declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
207
|
+
expect(removeConstraint(constraint.id)).toBe(true);
|
|
208
|
+
expect(getConstraintsForResource(resource.id)).toEqual([]);
|
|
209
|
+
expect(removeConstraint(constraint.id)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Cross-cutting tests for the opt-in "expiry consequence" capability across
|
|
2
|
+
// BOTH systems that support it: scheduled events (time.ts) and timers
|
|
3
|
+
// (timers.ts). Each system has its own focused unit tests in time.test.ts
|
|
4
|
+
// and timers.test.ts respectively; this file exists specifically to cover
|
|
5
|
+
// interleaving between them, since a DM session commonly has both a
|
|
6
|
+
// scheduled event and a countdown timer live at once, sometimes targeting
|
|
7
|
+
// the very same resource.
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { createTestDb, destroyTestDb } from '../../db/__tests__/testDb.js';
|
|
10
|
+
import { createGame } from '../game.js';
|
|
11
|
+
import { createResource, getResource, getResourceHistory } from '../resource.js';
|
|
12
|
+
import { setCalendar, advanceTime, scheduleEvent } from '../time.js';
|
|
13
|
+
import { createTimer, tickTimer } from '../timers.js';
|
|
14
|
+
const dt = (overrides = {}) => ({
|
|
15
|
+
year: 1,
|
|
16
|
+
month: 0,
|
|
17
|
+
day: 0,
|
|
18
|
+
hour: 8,
|
|
19
|
+
minute: 0,
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
describe('interleaved scheduled events and timers', () => {
|
|
23
|
+
let gameId;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
createTestDb();
|
|
26
|
+
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
27
|
+
setCalendar(gameId, {});
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
destroyTestDb();
|
|
31
|
+
});
|
|
32
|
+
it('applies a scheduled-event consequence and a timer consequence to the same resource, each exactly once, regardless of order', () => {
|
|
33
|
+
const treasury = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
|
|
34
|
+
scheduleEvent({
|
|
35
|
+
gameId,
|
|
36
|
+
name: 'toll collected',
|
|
37
|
+
triggerTime: dt({ hour: 9 }),
|
|
38
|
+
consequence: { resourceId: treasury.id, delta: -10 },
|
|
39
|
+
});
|
|
40
|
+
const timer = createTimer({
|
|
41
|
+
gameId,
|
|
42
|
+
name: 'ritual completes',
|
|
43
|
+
timerType: 'countdown',
|
|
44
|
+
currentValue: 2,
|
|
45
|
+
triggerAt: 0,
|
|
46
|
+
consequence: { resourceId: treasury.id, delta: -25 },
|
|
47
|
+
});
|
|
48
|
+
tickTimer(timer.id, 1); // 2 -> 1, not yet
|
|
49
|
+
advanceTime(gameId, { hours: 2 }); // crosses the event's trigger, -10
|
|
50
|
+
tickTimer(timer.id, 1); // 1 -> 0, crosses the timer's trigger, -25
|
|
51
|
+
expect(getResource(treasury.id)?.value).toBe(65);
|
|
52
|
+
const history = getResourceHistory(treasury.id);
|
|
53
|
+
expect(history).toHaveLength(2);
|
|
54
|
+
expect(history).toEqual(expect.arrayContaining([
|
|
55
|
+
expect.objectContaining({ delta: -10, reason: expect.stringContaining('toll collected') }),
|
|
56
|
+
expect.objectContaining({ delta: -25, reason: expect.stringContaining('ritual completes') }),
|
|
57
|
+
]));
|
|
58
|
+
});
|
|
59
|
+
it('ticking a timer in between two advanceTime calls does not disturb the scheduled event bookkeeping', () => {
|
|
60
|
+
const grain = createResource({ gameId, ownerType: 'game', name: 'grain', value: 50 });
|
|
61
|
+
const gold = createResource({ gameId, ownerType: 'game', name: 'gold', value: 50 });
|
|
62
|
+
scheduleEvent({
|
|
63
|
+
gameId,
|
|
64
|
+
name: 'spoilage',
|
|
65
|
+
triggerTime: dt({ hour: 12 }),
|
|
66
|
+
consequence: { resourceId: grain.id, delta: -5 },
|
|
67
|
+
});
|
|
68
|
+
const timer = createTimer({
|
|
69
|
+
gameId,
|
|
70
|
+
name: 'a spell wearing off',
|
|
71
|
+
timerType: 'countdown',
|
|
72
|
+
currentValue: 3,
|
|
73
|
+
triggerAt: 0,
|
|
74
|
+
consequence: { resourceId: gold.id, delta: -1 },
|
|
75
|
+
});
|
|
76
|
+
const first = advanceTime(gameId, { hours: 1 }); // 08:00 -> 09:00, event not due yet
|
|
77
|
+
expect(first?.triggeredEvents).toHaveLength(0);
|
|
78
|
+
tickTimer(timer.id, 1); // 3 -> 2
|
|
79
|
+
tickTimer(timer.id, 1); // 2 -> 1
|
|
80
|
+
expect(getResource(gold.id)?.value).toBe(50); // timer hasn't triggered yet
|
|
81
|
+
const second = advanceTime(gameId, { hours: 4 }); // 09:00 -> 13:00, crosses the event
|
|
82
|
+
expect(second?.triggeredEvents).toHaveLength(1);
|
|
83
|
+
expect(getResource(grain.id)?.value).toBe(45);
|
|
84
|
+
tickTimer(timer.id, 1); // 1 -> 0, triggers
|
|
85
|
+
expect(getResource(gold.id)?.value).toBe(49);
|
|
86
|
+
});
|
|
87
|
+
it('a failing timer consequence does not affect an independently-processed scheduled event, and vice versa', () => {
|
|
88
|
+
const treasury = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
|
|
89
|
+
scheduleEvent({
|
|
90
|
+
gameId,
|
|
91
|
+
name: 'healthy toll',
|
|
92
|
+
triggerTime: dt({ hour: 9 }),
|
|
93
|
+
consequence: { resourceId: treasury.id, delta: -10 },
|
|
94
|
+
});
|
|
95
|
+
const brokenTimer = createTimer({
|
|
96
|
+
gameId,
|
|
97
|
+
name: 'broken spell',
|
|
98
|
+
timerType: 'countdown',
|
|
99
|
+
currentValue: 1,
|
|
100
|
+
triggerAt: 0,
|
|
101
|
+
consequence: { resourceId: 'does-not-exist', delta: -999 },
|
|
102
|
+
});
|
|
103
|
+
const result = advanceTime(gameId, { hours: 2 });
|
|
104
|
+
expect(result?.triggeredEvents).toHaveLength(1);
|
|
105
|
+
expect(getResource(treasury.id)?.value).toBe(90);
|
|
106
|
+
expect(() => tickTimer(brokenTimer.id, 1)).toThrow();
|
|
107
|
+
// The unrelated, already-applied event consequence must be untouched.
|
|
108
|
+
expect(getResource(treasury.id)?.value).toBe(90);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { createTestDb, destroyTestDb } from '../../db/__tests__/testDb.js';
|
|
4
|
+
import { getDatabase } from '../../db/connection.js';
|
|
5
|
+
import { createGame } from '../game.js';
|
|
6
|
+
import { createCharacter } from '../character.js';
|
|
7
|
+
import { getImage, setPrimaryImage } from '../images.js';
|
|
8
|
+
// Inserts a stored_images row directly via SQL so these tests can exercise
|
|
9
|
+
// setPrimaryImage() without going through storeImage()'s file I/O / network
|
|
10
|
+
// fetch, which is unrelated to the atomicity behavior under test here.
|
|
11
|
+
function insertStoredImage(params) {
|
|
12
|
+
const db = getDatabase();
|
|
13
|
+
const id = uuidv4();
|
|
14
|
+
db.prepare(`
|
|
15
|
+
INSERT INTO stored_images (id, game_id, entity_id, entity_type, file_path, file_size, mime_type, source, is_primary, created_at)
|
|
16
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
17
|
+
`).run(id, params.gameId, params.entityId, params.entityType, `${id}.png`, 100, 'image/png', 'uploaded', params.isPrimary ? 1 : 0, new Date().toISOString());
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
describe('setPrimaryImage', () => {
|
|
21
|
+
let gameId;
|
|
22
|
+
let characterId;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
createTestDb();
|
|
25
|
+
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
26
|
+
characterId = createCharacter({ gameId, name: 'Narrator', isPlayer: false }).id;
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
destroyTestDb();
|
|
30
|
+
});
|
|
31
|
+
it('unsets the previous primary and sets the new one', () => {
|
|
32
|
+
const first = insertStoredImage({ gameId, entityId: characterId, entityType: 'character', isPrimary: true });
|
|
33
|
+
const second = insertStoredImage({ gameId, entityId: characterId, entityType: 'character', isPrimary: false });
|
|
34
|
+
setPrimaryImage(second);
|
|
35
|
+
expect(getImage(first)?.isPrimary).toBe(false);
|
|
36
|
+
expect(getImage(second)?.isPrimary).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('leaves NO partial write when the second update fails mid-operation: the old primary stays primary', () => {
|
|
39
|
+
const first = insertStoredImage({ gameId, entityId: characterId, entityType: 'character', isPrimary: true });
|
|
40
|
+
const second = insertStoredImage({ gameId, entityId: characterId, entityType: 'character', isPrimary: false });
|
|
41
|
+
// Fault injection: make the UPDATE that sets `second` as primary fail,
|
|
42
|
+
// simulating a mid-operation failure between the unset and the set.
|
|
43
|
+
const db = getDatabase();
|
|
44
|
+
db.exec(`
|
|
45
|
+
CREATE TRIGGER fail_set_new_primary_image
|
|
46
|
+
BEFORE UPDATE ON stored_images
|
|
47
|
+
WHEN NEW.id = '${second}' AND NEW.is_primary = 1
|
|
48
|
+
BEGIN
|
|
49
|
+
SELECT RAISE(ABORT, 'simulated failure');
|
|
50
|
+
END;
|
|
51
|
+
`);
|
|
52
|
+
expect(() => setPrimaryImage(second)).toThrow();
|
|
53
|
+
// Without a real transaction, the first UPDATE (unsetting `first`)
|
|
54
|
+
// would have already committed, leaving NO primary at all. With a
|
|
55
|
+
// transaction, `first` must still be primary.
|
|
56
|
+
expect(getImage(first)?.isPrimary).toBe(true);
|
|
57
|
+
expect(getImage(second)?.isPrimary).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createTestDb, destroyTestDb } from '../../db/__tests__/testDb.js';
|
|
3
|
+
import { getDatabase } from '../../db/connection.js';
|
|
4
|
+
import { createGame } from '../game.js';
|
|
5
|
+
import { createBidirectionalRelationship, createRelationship, getRelationship, getRelationshipHistory, modifyRelationship, updateRelationshipValue, } from '../relationship.js';
|
|
6
|
+
describe('createBidirectionalRelationship', () => {
|
|
7
|
+
let gameId;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
createTestDb();
|
|
10
|
+
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
destroyTestDb();
|
|
14
|
+
});
|
|
15
|
+
it('creates two relationship rows, one in each direction', () => {
|
|
16
|
+
const [relA, relB] = createBidirectionalRelationship({
|
|
17
|
+
gameId,
|
|
18
|
+
entityA: { id: 'alice', type: 'character' },
|
|
19
|
+
entityB: { id: 'bob', type: 'character' },
|
|
20
|
+
relationshipType: 'ally',
|
|
21
|
+
value: 10,
|
|
22
|
+
});
|
|
23
|
+
expect(relA.sourceId).toBe('alice');
|
|
24
|
+
expect(relA.targetId).toBe('bob');
|
|
25
|
+
expect(relB.sourceId).toBe('bob');
|
|
26
|
+
expect(relB.targetId).toBe('alice');
|
|
27
|
+
});
|
|
28
|
+
it('leaves NO partial write when the second insert fails mid-operation', () => {
|
|
29
|
+
const db = getDatabase();
|
|
30
|
+
// Fault injection: make the INSERT for the second (bob -> alice) row
|
|
31
|
+
// fail, simulating a mid-operation failure such as a constraint
|
|
32
|
+
// violation or disk error.
|
|
33
|
+
db.exec(`
|
|
34
|
+
CREATE TRIGGER fail_second_relationship_insert
|
|
35
|
+
BEFORE INSERT ON relationships
|
|
36
|
+
WHEN NEW.source_id = 'bob'
|
|
37
|
+
BEGIN
|
|
38
|
+
SELECT RAISE(ABORT, 'simulated failure');
|
|
39
|
+
END;
|
|
40
|
+
`);
|
|
41
|
+
expect(() => createBidirectionalRelationship({
|
|
42
|
+
gameId,
|
|
43
|
+
entityA: { id: 'alice', type: 'character' },
|
|
44
|
+
entityB: { id: 'bob', type: 'character' },
|
|
45
|
+
relationshipType: 'ally',
|
|
46
|
+
value: 10,
|
|
47
|
+
})).toThrow();
|
|
48
|
+
const rows = db.prepare('SELECT * FROM relationships').all();
|
|
49
|
+
expect(rows).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('modifyRelationship', () => {
|
|
53
|
+
let gameId;
|
|
54
|
+
let relationshipId;
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
createTestDb();
|
|
57
|
+
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
58
|
+
relationshipId = createRelationship({
|
|
59
|
+
gameId,
|
|
60
|
+
sourceId: 'alice',
|
|
61
|
+
sourceType: 'character',
|
|
62
|
+
targetId: 'bob',
|
|
63
|
+
targetType: 'character',
|
|
64
|
+
relationshipType: 'ally',
|
|
65
|
+
value: 0,
|
|
66
|
+
}).id;
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
destroyTestDb();
|
|
70
|
+
});
|
|
71
|
+
it('updates the value and logs a history row together', () => {
|
|
72
|
+
const result = modifyRelationship({ relationshipId, delta: 5, reason: 'helped in a fight' });
|
|
73
|
+
expect(result?.relationship.value).toBe(5);
|
|
74
|
+
const history = getRelationshipHistory(relationshipId);
|
|
75
|
+
expect(history).toHaveLength(1);
|
|
76
|
+
expect(history[0]).toMatchObject({ previousValue: 0, newValue: 5, reason: 'helped in a fight' });
|
|
77
|
+
});
|
|
78
|
+
it('leaves NO partial write when the history insert fails mid-operation', () => {
|
|
79
|
+
const db = getDatabase();
|
|
80
|
+
// Fault injection: make the relationship_history INSERT fail after the
|
|
81
|
+
// relationships UPDATE has already run.
|
|
82
|
+
db.exec(`
|
|
83
|
+
CREATE TRIGGER fail_relationship_history_insert
|
|
84
|
+
BEFORE INSERT ON relationship_history
|
|
85
|
+
BEGIN
|
|
86
|
+
SELECT RAISE(ABORT, 'simulated failure');
|
|
87
|
+
END;
|
|
88
|
+
`);
|
|
89
|
+
expect(() => modifyRelationship({ relationshipId, delta: 5, reason: 'helped in a fight' })).toThrow();
|
|
90
|
+
expect(getRelationship(relationshipId)?.value).toBe(0);
|
|
91
|
+
expect(getRelationshipHistory(relationshipId)).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('updateRelationshipValue', () => {
|
|
95
|
+
let gameId;
|
|
96
|
+
let relationshipId;
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
createTestDb();
|
|
99
|
+
gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
|
|
100
|
+
relationshipId = createRelationship({
|
|
101
|
+
gameId,
|
|
102
|
+
sourceId: 'alice',
|
|
103
|
+
sourceType: 'character',
|
|
104
|
+
targetId: 'bob',
|
|
105
|
+
targetType: 'character',
|
|
106
|
+
relationshipType: 'ally',
|
|
107
|
+
value: 0,
|
|
108
|
+
}).id;
|
|
109
|
+
});
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
destroyTestDb();
|
|
112
|
+
});
|
|
113
|
+
it('updates the value and logs history when the value changes', () => {
|
|
114
|
+
const result = updateRelationshipValue({ relationshipId, mode: 'set', value: 20, reason: 'reconciled' });
|
|
115
|
+
expect(result?.relationship.value).toBe(20);
|
|
116
|
+
expect(result?.change).toMatchObject({ previousValue: 0, newValue: 20, reason: 'reconciled' });
|
|
117
|
+
});
|
|
118
|
+
it('leaves NO partial write when the history insert fails mid-operation', () => {
|
|
119
|
+
const db = getDatabase();
|
|
120
|
+
db.exec(`
|
|
121
|
+
CREATE TRIGGER fail_relationship_history_insert_2
|
|
122
|
+
BEFORE INSERT ON relationship_history
|
|
123
|
+
BEGIN
|
|
124
|
+
SELECT RAISE(ABORT, 'simulated failure');
|
|
125
|
+
END;
|
|
126
|
+
`);
|
|
127
|
+
expect(() => updateRelationshipValue({ relationshipId, mode: 'set', value: 20, reason: 'reconciled' })).toThrow();
|
|
128
|
+
// Value must not have moved, since the paired history write failed.
|
|
129
|
+
expect(getRelationship(relationshipId)?.value).toBe(0);
|
|
130
|
+
expect(getRelationshipHistory(relationshipId)).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Integration tests: does updateResourceValue() actually enforce declared
|
|
2
|
+
// constraints, and does it leave unconstrained resources exactly as they
|
|
3
|
+
// behaved before this feature existed?
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { createTestDb, destroyTestDb } from "../../db/__tests__/testDb.js";
|
|
6
|
+
import { createGame } from "../game.js";
|
|
7
|
+
import { createResource, getResource, updateResourceValue } from "../resource.js";
|
|
8
|
+
import { declareBoundedConstraint, declareMonotonicConstraint, ConstraintViolationError, } from "../constraint.js";
|
|
9
|
+
describe("constraint enforcement on updateResourceValue", () => {
|
|
10
|
+
let gameId;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
createTestDb();
|
|
13
|
+
gameId = createGame({ name: "Test Game", setting: "test", style: "test" }).id;
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
destroyTestDb();
|
|
17
|
+
});
|
|
18
|
+
describe("no constraint declared (baseline, must be unchanged)", () => {
|
|
19
|
+
it("still silently clamps to min/max as before", () => {
|
|
20
|
+
const resource = createResource({
|
|
21
|
+
gameId,
|
|
22
|
+
ownerType: "game",
|
|
23
|
+
name: "grain",
|
|
24
|
+
value: 50,
|
|
25
|
+
minValue: 0,
|
|
26
|
+
maxValue: 100,
|
|
27
|
+
});
|
|
28
|
+
const result = updateResourceValue({ resourceId: resource.id, mode: "set", value: 999 });
|
|
29
|
+
expect(result?.resource.value).toBe(100);
|
|
30
|
+
expect(getResource(resource.id)?.value).toBe(100);
|
|
31
|
+
});
|
|
32
|
+
it("allows values to move in either direction with no monotonic constraint", () => {
|
|
33
|
+
const resource = createResource({ gameId, ownerType: "game", name: "population", value: 10 });
|
|
34
|
+
updateResourceValue({ resourceId: resource.id, mode: "delta", value: 5 });
|
|
35
|
+
const down = updateResourceValue({ resourceId: resource.id, mode: "delta", value: -20 });
|
|
36
|
+
expect(down?.resource.value).toBe(-5);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("bounded constraint declared", () => {
|
|
40
|
+
it("rejects (does not clamp) a 'set' that would exceed the max", () => {
|
|
41
|
+
const resource = createResource({
|
|
42
|
+
gameId,
|
|
43
|
+
ownerType: "game",
|
|
44
|
+
name: "grain",
|
|
45
|
+
value: 50,
|
|
46
|
+
minValue: 0,
|
|
47
|
+
maxValue: 100,
|
|
48
|
+
});
|
|
49
|
+
declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
50
|
+
expect(() => updateResourceValue({ resourceId: resource.id, mode: "set", value: 999 })).toThrow(ConstraintViolationError);
|
|
51
|
+
// Value must be untouched -- rejection, not a partial/clamped write.
|
|
52
|
+
expect(getResource(resource.id)?.value).toBe(50);
|
|
53
|
+
});
|
|
54
|
+
it("rejects a delta that would go below the min", () => {
|
|
55
|
+
const resource = createResource({
|
|
56
|
+
gameId,
|
|
57
|
+
ownerType: "game",
|
|
58
|
+
name: "grain",
|
|
59
|
+
value: 10,
|
|
60
|
+
minValue: 0,
|
|
61
|
+
maxValue: 100,
|
|
62
|
+
});
|
|
63
|
+
declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
64
|
+
expect(() => updateResourceValue({ resourceId: resource.id, mode: "delta", value: -50 })).toThrow(ConstraintViolationError);
|
|
65
|
+
expect(getResource(resource.id)?.value).toBe(10);
|
|
66
|
+
});
|
|
67
|
+
it("allows an in-bounds change through untouched", () => {
|
|
68
|
+
const resource = createResource({
|
|
69
|
+
gameId,
|
|
70
|
+
ownerType: "game",
|
|
71
|
+
name: "grain",
|
|
72
|
+
value: 50,
|
|
73
|
+
minValue: 0,
|
|
74
|
+
maxValue: 100,
|
|
75
|
+
});
|
|
76
|
+
declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
77
|
+
const result = updateResourceValue({ resourceId: resource.id, mode: "delta", value: 25 });
|
|
78
|
+
expect(result?.resource.value).toBe(75);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("monotonic constraint declared", () => {
|
|
82
|
+
it("rejects a decrease on an 'increasing' (never-decreasing) resource", () => {
|
|
83
|
+
const resource = createResource({ gameId, ownerType: "game", name: "population", value: 100 });
|
|
84
|
+
declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "increasing" });
|
|
85
|
+
expect(() => updateResourceValue({ resourceId: resource.id, mode: "delta", value: -1 })).toThrow(ConstraintViolationError);
|
|
86
|
+
expect(getResource(resource.id)?.value).toBe(100);
|
|
87
|
+
});
|
|
88
|
+
it("allows an increase on an 'increasing' resource", () => {
|
|
89
|
+
const resource = createResource({ gameId, ownerType: "game", name: "population", value: 100 });
|
|
90
|
+
declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "increasing" });
|
|
91
|
+
const result = updateResourceValue({ resourceId: resource.id, mode: "delta", value: 5 });
|
|
92
|
+
expect(result?.resource.value).toBe(105);
|
|
93
|
+
});
|
|
94
|
+
it("rejects an increase on a 'decreasing' (never-increasing) resource", () => {
|
|
95
|
+
const resource = createResource({ gameId, ownerType: "game", name: "countdown", value: 10 });
|
|
96
|
+
declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "decreasing" });
|
|
97
|
+
expect(() => updateResourceValue({ resourceId: resource.id, mode: "set", value: 20 })).toThrow(ConstraintViolationError);
|
|
98
|
+
expect(getResource(resource.id)?.value).toBe(10);
|
|
99
|
+
});
|
|
100
|
+
it("allows a value to stay the same (not a strict decrease requirement)", () => {
|
|
101
|
+
const resource = createResource({ gameId, ownerType: "game", name: "countdown", value: 10 });
|
|
102
|
+
declareMonotonicConstraint({ gameId, resourceId: resource.id, direction: "decreasing" });
|
|
103
|
+
const result = updateResourceValue({ resourceId: resource.id, mode: "delta", value: 0 });
|
|
104
|
+
expect(result?.resource.value).toBe(10);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("thrown error shape", () => {
|
|
108
|
+
it("ConstraintViolationError carries the resourceId and constraint kind", () => {
|
|
109
|
+
const resource = createResource({
|
|
110
|
+
gameId,
|
|
111
|
+
ownerType: "game",
|
|
112
|
+
name: "grain",
|
|
113
|
+
value: 50,
|
|
114
|
+
minValue: 0,
|
|
115
|
+
maxValue: 100,
|
|
116
|
+
});
|
|
117
|
+
declareBoundedConstraint({ gameId, resourceId: resource.id });
|
|
118
|
+
try {
|
|
119
|
+
updateResourceValue({ resourceId: resource.id, mode: "set", value: 999 });
|
|
120
|
+
expect.unreachable("expected updateResourceValue to throw");
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
expect(error).toBeInstanceOf(ConstraintViolationError);
|
|
124
|
+
const violation = error;
|
|
125
|
+
expect(violation.resourceId).toBe(resource.id);
|
|
126
|
+
expect(violation.constraintKind).toBe("bounded");
|
|
127
|
+
expect(violation.message).toBeTruthy();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|