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.
Files changed (167) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +79 -0
  3. package/dist/__tests__/engineVocabulary.test.d.ts +1 -0
  4. package/dist/__tests__/engineVocabulary.test.js +147 -0
  5. package/dist/db/__tests__/connection.test.d.ts +1 -0
  6. package/dist/db/__tests__/connection.test.js +72 -0
  7. package/dist/db/__tests__/testDb.d.ts +33 -0
  8. package/dist/db/__tests__/testDb.js +41 -0
  9. package/dist/db/connection.d.ts +22 -0
  10. package/dist/db/connection.js +107 -0
  11. package/dist/db/schema.d.ts +1 -0
  12. package/dist/db/schema.js +725 -0
  13. package/dist/events/emitter.d.ts +22 -0
  14. package/dist/events/emitter.js +71 -0
  15. package/dist/http/server.d.ts +3 -0
  16. package/dist/http/server.js +649 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +92 -0
  19. package/dist/register/abilities.d.ts +2 -0
  20. package/dist/register/abilities.js +165 -0
  21. package/dist/register/audio.d.ts +2 -0
  22. package/dist/register/audio.js +326 -0
  23. package/dist/register/batch.d.ts +2 -0
  24. package/dist/register/batch.js +343 -0
  25. package/dist/register/character.d.ts +2 -0
  26. package/dist/register/character.js +324 -0
  27. package/dist/register/combat.d.ts +2 -0
  28. package/dist/register/combat.js +207 -0
  29. package/dist/register/core.d.ts +2 -0
  30. package/dist/register/core.js +1040 -0
  31. package/dist/register/display.d.ts +2 -0
  32. package/dist/register/display.js +263 -0
  33. package/dist/register/factions.d.ts +2 -0
  34. package/dist/register/factions.js +186 -0
  35. package/dist/register/images.d.ts +2 -0
  36. package/dist/register/images.js +400 -0
  37. package/dist/register/inventory.d.ts +2 -0
  38. package/dist/register/inventory.js +115 -0
  39. package/dist/register/mcp-prompts.d.ts +2 -0
  40. package/dist/register/mcp-prompts.js +684 -0
  41. package/dist/register/mcp-resources.d.ts +2 -0
  42. package/dist/register/mcp-resources.js +335 -0
  43. package/dist/register/narrative.d.ts +2 -0
  44. package/dist/register/narrative.js +242 -0
  45. package/dist/register/notes.d.ts +2 -0
  46. package/dist/register/notes.js +170 -0
  47. package/dist/register/pause.d.ts +2 -0
  48. package/dist/register/pause.js +580 -0
  49. package/dist/register/quests.d.ts +2 -0
  50. package/dist/register/quests.js +118 -0
  51. package/dist/register/relationships.d.ts +2 -0
  52. package/dist/register/relationships.js +147 -0
  53. package/dist/register/resources.d.ts +2 -0
  54. package/dist/register/resources.js +277 -0
  55. package/dist/register/secrets.d.ts +2 -0
  56. package/dist/register/secrets.js +192 -0
  57. package/dist/register/status.d.ts +2 -0
  58. package/dist/register/status.js +130 -0
  59. package/dist/register/tables.d.ts +2 -0
  60. package/dist/register/tables.js +146 -0
  61. package/dist/register/tags.d.ts +2 -0
  62. package/dist/register/tags.js +114 -0
  63. package/dist/register/time.d.ts +2 -0
  64. package/dist/register/time.js +281 -0
  65. package/dist/register/world.d.ts +2 -0
  66. package/dist/register/world.js +127 -0
  67. package/dist/schemas/index.d.ts +921 -0
  68. package/dist/schemas/index.js +121 -0
  69. package/dist/test-setup.d.ts +1 -0
  70. package/dist/test-setup.js +13 -0
  71. package/dist/tools/__tests__/audio.test.d.ts +1 -0
  72. package/dist/tools/__tests__/audio.test.js +59 -0
  73. package/dist/tools/__tests__/conserved.test.d.ts +1 -0
  74. package/dist/tools/__tests__/conserved.test.js +488 -0
  75. package/dist/tools/__tests__/constraint.test.d.ts +1 -0
  76. package/dist/tools/__tests__/constraint.test.js +212 -0
  77. package/dist/tools/__tests__/expiry-consequences.test.d.ts +1 -0
  78. package/dist/tools/__tests__/expiry-consequences.test.js +110 -0
  79. package/dist/tools/__tests__/images.test.d.ts +1 -0
  80. package/dist/tools/__tests__/images.test.js +59 -0
  81. package/dist/tools/__tests__/relationship.test.d.ts +1 -0
  82. package/dist/tools/__tests__/relationship.test.js +132 -0
  83. package/dist/tools/__tests__/resource-constraints.test.d.ts +1 -0
  84. package/dist/tools/__tests__/resource-constraints.test.js +131 -0
  85. package/dist/tools/__tests__/resource.test.d.ts +1 -0
  86. package/dist/tools/__tests__/resource.test.js +190 -0
  87. package/dist/tools/__tests__/time.test.d.ts +1 -0
  88. package/dist/tools/__tests__/time.test.js +404 -0
  89. package/dist/tools/__tests__/timers.test.d.ts +1 -0
  90. package/dist/tools/__tests__/timers.test.js +426 -0
  91. package/dist/tools/__tests__/world.test.d.ts +1 -0
  92. package/dist/tools/__tests__/world.test.js +70 -0
  93. package/dist/tools/ability.d.ts +48 -0
  94. package/dist/tools/ability.js +238 -0
  95. package/dist/tools/audio.d.ts +24 -0
  96. package/dist/tools/audio.js +365 -0
  97. package/dist/tools/character.d.ts +70 -0
  98. package/dist/tools/character.js +309 -0
  99. package/dist/tools/combat.d.ts +13 -0
  100. package/dist/tools/combat.js +195 -0
  101. package/dist/tools/constraint.d.ts +132 -0
  102. package/dist/tools/constraint.js +269 -0
  103. package/dist/tools/dice.d.ts +23 -0
  104. package/dist/tools/dice.js +111 -0
  105. package/dist/tools/display.d.ts +120 -0
  106. package/dist/tools/display.js +528 -0
  107. package/dist/tools/faction.d.ts +61 -0
  108. package/dist/tools/faction.js +269 -0
  109. package/dist/tools/game.d.ts +96 -0
  110. package/dist/tools/game.js +526 -0
  111. package/dist/tools/image-prompt.d.ts +49 -0
  112. package/dist/tools/image-prompt.js +479 -0
  113. package/dist/tools/images.d.ts +47 -0
  114. package/dist/tools/images.js +449 -0
  115. package/dist/tools/inventory.d.ts +20 -0
  116. package/dist/tools/inventory.js +145 -0
  117. package/dist/tools/narrative.d.ts +58 -0
  118. package/dist/tools/narrative.js +237 -0
  119. package/dist/tools/notes.d.ts +41 -0
  120. package/dist/tools/notes.js +220 -0
  121. package/dist/tools/pause.d.ts +110 -0
  122. package/dist/tools/pause.js +1254 -0
  123. package/dist/tools/quest.d.ts +34 -0
  124. package/dist/tools/quest.js +164 -0
  125. package/dist/tools/relationship.d.ts +74 -0
  126. package/dist/tools/relationship.js +324 -0
  127. package/dist/tools/resource.d.ts +93 -0
  128. package/dist/tools/resource.js +374 -0
  129. package/dist/tools/rules.d.ts +4 -0
  130. package/dist/tools/rules.js +30 -0
  131. package/dist/tools/secrets.d.ts +49 -0
  132. package/dist/tools/secrets.js +195 -0
  133. package/dist/tools/status.d.ts +36 -0
  134. package/dist/tools/status.js +218 -0
  135. package/dist/tools/tables.d.ts +33 -0
  136. package/dist/tools/tables.js +209 -0
  137. package/dist/tools/tags.d.ts +52 -0
  138. package/dist/tools/tags.js +176 -0
  139. package/dist/tools/time.d.ts +33 -0
  140. package/dist/tools/time.js +276 -0
  141. package/dist/tools/timers.d.ts +41 -0
  142. package/dist/tools/timers.js +215 -0
  143. package/dist/tools/world.d.ts +78 -0
  144. package/dist/tools/world.js +331 -0
  145. package/dist/types/index.d.ts +969 -0
  146. package/dist/types/index.js +1 -0
  147. package/dist/utils/__tests__/json.test.d.ts +1 -0
  148. package/dist/utils/__tests__/json.test.js +55 -0
  149. package/dist/utils/__tests__/validation.test.d.ts +1 -0
  150. package/dist/utils/__tests__/validation.test.js +90 -0
  151. package/dist/utils/errors.d.ts +44 -0
  152. package/dist/utils/errors.js +121 -0
  153. package/dist/utils/json.d.ts +9 -0
  154. package/dist/utils/json.js +23 -0
  155. package/dist/utils/logger.d.ts +7 -0
  156. package/dist/utils/logger.js +50 -0
  157. package/dist/utils/output-schemas.d.ts +594 -0
  158. package/dist/utils/output-schemas.js +331 -0
  159. package/dist/utils/tool-annotations.d.ts +147 -0
  160. package/dist/utils/tool-annotations.js +98 -0
  161. package/dist/utils/validation.d.ts +34 -0
  162. package/dist/utils/validation.js +52 -0
  163. package/dist/utils/verbosity.d.ts +57 -0
  164. package/dist/utils/verbosity.js +67 -0
  165. package/dist/utils/webui.d.ts +20 -0
  166. package/dist/utils/webui.js +35 -0
  167. package/package.json +75 -0
@@ -0,0 +1,488 @@
1
+ // Exhaustive coverage for 'conserved' constraint enforcement.
2
+ //
3
+ // A 'conserved' constraint declares that a *set* of resources must always
4
+ // sum to a fixed total. Enforcing it means: a write to one member cannot be
5
+ // accepted or rejected in isolation, because the server has no way to know
6
+ // where the counterpart delta should come from. See transferResourceValue()
7
+ // in resource.ts for the chosen API (an explicit two-resource transfer) and
8
+ // the rejection of the ambiguous single-resource write.
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import { createTestDb, destroyTestDb } from "../../db/__tests__/testDb.js";
11
+ import { getDatabase, withTransaction } from "../../db/connection.js";
12
+ import { createGame } from "../game.js";
13
+ import { createResource, getResource, getResourceHistory, updateResourceValue, updateResource, deleteResource, transferResourceValue, } from "../resource.js";
14
+ import { declareConservedConstraint, declareBoundedConstraint, declareMonotonicConstraint, removeConstraint, ConstraintViolationError, } from "../constraint.js";
15
+ describe("conserved constraint enforcement", () => {
16
+ let gameId;
17
+ beforeEach(() => {
18
+ createTestDb();
19
+ gameId = createGame({ name: "Test Game", setting: "test", style: "test" }).id;
20
+ });
21
+ afterEach(() => {
22
+ destroyTestDb();
23
+ });
24
+ function makePair(aValue, bValue, total = aValue + bValue) {
25
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: aValue });
26
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: bValue });
27
+ const constraint = declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total });
28
+ return { a, b, constraint };
29
+ }
30
+ // --------------------------------------------------------------------
31
+ // The ambiguous single-resource write
32
+ // --------------------------------------------------------------------
33
+ describe("update_resource_value against a conserved member (ambiguous single write)", () => {
34
+ it("rejects a delta-mode write to a conserved member", () => {
35
+ const { a } = makePair(40, 60);
36
+ expect(() => updateResourceValue({ resourceId: a.id, mode: "delta", value: 5 })).toThrow(ConstraintViolationError);
37
+ expect(getResource(a.id)?.value).toBe(40);
38
+ });
39
+ it("rejects a set-mode write to a conserved member", () => {
40
+ const { a } = makePair(40, 60);
41
+ expect(() => updateResourceValue({ resourceId: a.id, mode: "set", value: 41 })).toThrow(ConstraintViolationError);
42
+ expect(getResource(a.id)?.value).toBe(40);
43
+ });
44
+ it("rejects even a zero-delta write (still ambiguous, still rejected)", () => {
45
+ const { a } = makePair(40, 60);
46
+ expect(() => updateResourceValue({ resourceId: a.id, mode: "delta", value: 0 })).toThrow(ConstraintViolationError);
47
+ });
48
+ it("the rejection error names the constraint kind and points at the transfer tool", () => {
49
+ const { a } = makePair(40, 60);
50
+ try {
51
+ updateResourceValue({ resourceId: a.id, mode: "delta", value: 5 });
52
+ expect.unreachable("expected updateResourceValue to throw");
53
+ }
54
+ catch (error) {
55
+ expect(error).toBeInstanceOf(ConstraintViolationError);
56
+ const violation = error;
57
+ expect(violation.constraintKind).toBe("conserved");
58
+ expect(violation.resourceId).toBe(a.id);
59
+ expect(violation.message).toMatch(/transfer_resource_value|transferResourceValue/i);
60
+ }
61
+ });
62
+ it("writes no history row for a rejected conserved-member write", () => {
63
+ const { a } = makePair(40, 60);
64
+ expect(() => updateResourceValue({ resourceId: a.id, mode: "delta", value: 5 })).toThrow();
65
+ expect(getResourceHistory(a.id)).toHaveLength(0);
66
+ });
67
+ it("both members of the set are equally protected, not just the first-declared one", () => {
68
+ const { b } = makePair(40, 60);
69
+ expect(() => updateResourceValue({ resourceId: b.id, mode: "delta", value: -5 })).toThrow(ConstraintViolationError);
70
+ expect(getResource(b.id)?.value).toBe(60);
71
+ });
72
+ it("after the constraint is removed, direct writes to the former member work normally again", () => {
73
+ const { a, constraint } = makePair(40, 60);
74
+ removeConstraint(constraint.id);
75
+ const result = updateResourceValue({ resourceId: a.id, mode: "delta", value: 5 });
76
+ expect(result?.resource.value).toBe(45);
77
+ });
78
+ });
79
+ // --------------------------------------------------------------------
80
+ // transferResourceValue: happy paths
81
+ // --------------------------------------------------------------------
82
+ describe("transferResourceValue: happy paths", () => {
83
+ it("moves value from one member to the other, preserving the total", () => {
84
+ const { a, b } = makePair(40, 60);
85
+ const result = transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
86
+ expect(result.from.value).toBe(25);
87
+ expect(result.to.value).toBe(75);
88
+ expect(result.from.value + result.to.value).toBe(100);
89
+ });
90
+ it("persists both new values to the database", () => {
91
+ const { a, b } = makePair(40, 60);
92
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
93
+ expect(getResource(a.id)?.value).toBe(25);
94
+ expect(getResource(b.id)?.value).toBe(75);
95
+ });
96
+ it("returns a ResourceChange for each side with correct previous/new/delta", () => {
97
+ const { a, b } = makePair(40, 60);
98
+ const result = transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15, reason: "trade" });
99
+ expect(result.fromChange).toMatchObject({
100
+ resourceId: a.id,
101
+ previousValue: 40,
102
+ newValue: 25,
103
+ delta: -15,
104
+ reason: "trade",
105
+ });
106
+ expect(result.toChange).toMatchObject({
107
+ resourceId: b.id,
108
+ previousValue: 60,
109
+ newValue: 75,
110
+ delta: 15,
111
+ reason: "trade",
112
+ });
113
+ });
114
+ it("writes one resource_history row per side", () => {
115
+ const { a, b } = makePair(40, 60);
116
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15, reason: "trade" });
117
+ expect(getResourceHistory(a.id)).toHaveLength(1);
118
+ expect(getResourceHistory(b.id)).toHaveLength(1);
119
+ });
120
+ it("defaults reason to null when not provided", () => {
121
+ const { a, b } = makePair(40, 60);
122
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
123
+ expect(getResourceHistory(a.id)[0].reason).toBeNull();
124
+ expect(getResourceHistory(b.id)[0].reason).toBeNull();
125
+ });
126
+ it("transfers of amount 0 succeed as a no-op (values unchanged, still logged)", () => {
127
+ const { a, b } = makePair(40, 60);
128
+ const result = transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 0 });
129
+ expect(result.from.value).toBe(40);
130
+ expect(result.to.value).toBe(60);
131
+ expect(getResourceHistory(a.id)).toHaveLength(1);
132
+ expect(getResourceHistory(a.id)[0].delta).toBe(0);
133
+ });
134
+ it("transfers the full balance, leaving the source at exactly zero", () => {
135
+ const { a, b } = makePair(40, 60);
136
+ const result = transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 40 });
137
+ expect(result.from.value).toBe(0);
138
+ expect(result.to.value).toBe(100);
139
+ });
140
+ it("allows a member to go negative when it has no minValue set", () => {
141
+ const { a, b } = makePair(40, 60);
142
+ const result = transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 55 });
143
+ expect(result.from.value).toBe(-15);
144
+ expect(result.to.value).toBe(115);
145
+ expect(result.from.value + result.to.value).toBe(100);
146
+ });
147
+ it("chains correctly across multiple sequential transfers", () => {
148
+ const { a, b } = makePair(40, 60);
149
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 10 });
150
+ transferResourceValue({ fromResourceId: b.id, toResourceId: a.id, amount: 25 });
151
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 5 });
152
+ // 40-10+25-5 = 50 ; 60+10-25+5 = 50
153
+ expect(getResource(a.id)?.value).toBe(50);
154
+ expect(getResource(b.id)?.value).toBe(50);
155
+ });
156
+ it("leaves the third member of a 3-member set untouched by a transfer between the other two", () => {
157
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 20 });
158
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 30 });
159
+ const c = createResource({ gameId, ownerType: "game", name: "seed_grain", value: 50 });
160
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id, c.id], total: 100 });
161
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 10 });
162
+ expect(getResource(a.id)?.value).toBe(10);
163
+ expect(getResource(b.id)?.value).toBe(40);
164
+ expect(getResource(c.id)?.value).toBe(50);
165
+ const sum = (getResource(a.id)?.value ?? 0) + (getResource(b.id)?.value ?? 0) + (getResource(c.id)?.value ?? 0);
166
+ expect(sum).toBe(100);
167
+ });
168
+ it("two independent conserved sets in the same game do not interfere with each other", () => {
169
+ const { a, b } = makePair(40, 60); // set 1, total 100
170
+ const c = createResource({ gameId, ownerType: "game", name: "population", value: 5 });
171
+ const d = createResource({ gameId, ownerType: "game", name: "casualties", value: 5 });
172
+ declareConservedConstraint({ gameId, resourceIds: [c.id, d.id], total: 10 }); // set 2
173
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 20 });
174
+ expect(getResource(c.id)?.value).toBe(5);
175
+ expect(getResource(d.id)?.value).toBe(5);
176
+ });
177
+ });
178
+ // --------------------------------------------------------------------
179
+ // transferResourceValue: rejection paths
180
+ // --------------------------------------------------------------------
181
+ describe("transferResourceValue: rejection paths", () => {
182
+ it("rejects when fromResourceId does not exist", () => {
183
+ const { b } = makePair(40, 60);
184
+ expect(() => transferResourceValue({ fromResourceId: "does-not-exist", toResourceId: b.id, amount: 5 })).toThrow();
185
+ });
186
+ it("rejects when toResourceId does not exist", () => {
187
+ const { a } = makePair(40, 60);
188
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: "does-not-exist", amount: 5 })).toThrow();
189
+ });
190
+ it("rejects transferring a resource to itself", () => {
191
+ const { a } = makePair(40, 60);
192
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: a.id, amount: 5 })).toThrow(/itself/i);
193
+ });
194
+ it("rejects a negative amount", () => {
195
+ const { a, b } = makePair(40, 60);
196
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: -5 })).toThrow(/negative|>= 0/i);
197
+ expect(getResource(a.id)?.value).toBe(40);
198
+ expect(getResource(b.id)?.value).toBe(60);
199
+ });
200
+ it("rejects a NaN amount", () => {
201
+ const { a, b } = makePair(40, 60);
202
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: NaN })).toThrow();
203
+ });
204
+ it("rejects an Infinity amount", () => {
205
+ const { a, b } = makePair(40, 60);
206
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: Infinity })).toThrow();
207
+ });
208
+ it("rejects a transfer between two resources that are not in any conserved constraint", () => {
209
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 10 });
210
+ const b = createResource({ gameId, ownerType: "game", name: "treasury", value: 10 });
211
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 5 })).toThrow(ConstraintViolationError);
212
+ expect(getResource(a.id)?.value).toBe(10);
213
+ expect(getResource(b.id)?.value).toBe(10);
214
+ });
215
+ it("rejects a transfer where only the source is a conserved member", () => {
216
+ const { a } = makePair(40, 60);
217
+ const outsider = createResource({ gameId, ownerType: "game", name: "treasury", value: 10 });
218
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: outsider.id, amount: 5 })).toThrow(ConstraintViolationError);
219
+ expect(getResource(a.id)?.value).toBe(40);
220
+ expect(getResource(outsider.id)?.value).toBe(10);
221
+ });
222
+ it("rejects a transfer where only the destination is a conserved member", () => {
223
+ const { b } = makePair(40, 60);
224
+ const outsider = createResource({ gameId, ownerType: "game", name: "treasury", value: 10 });
225
+ expect(() => transferResourceValue({ fromResourceId: outsider.id, toResourceId: b.id, amount: 5 })).toThrow(ConstraintViolationError);
226
+ });
227
+ it("rejects a transfer between members of two different conserved constraints", () => {
228
+ const { a } = makePair(40, 60); // set 1
229
+ const c = createResource({ gameId, ownerType: "game", name: "population", value: 5 });
230
+ const d = createResource({ gameId, ownerType: "game", name: "casualties", value: 5 });
231
+ declareConservedConstraint({ gameId, resourceIds: [c.id, d.id], total: 10 }); // set 2
232
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: c.id, amount: 5 })).toThrow(ConstraintViolationError);
233
+ });
234
+ it("never clamps: rejects (not clamps) a transfer that would take a bounded source below its minValue", () => {
235
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40, minValue: 0 });
236
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
237
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
238
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 50 })).toThrow();
239
+ expect(getResource(a.id)?.value).toBe(40);
240
+ expect(getResource(b.id)?.value).toBe(60);
241
+ });
242
+ it("never clamps: rejects a transfer that would push the destination above its maxValue", () => {
243
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
244
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60, maxValue: 70 });
245
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
246
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 })).toThrow();
247
+ expect(getResource(a.id)?.value).toBe(40);
248
+ expect(getResource(b.id)?.value).toBe(60);
249
+ });
250
+ it("respects an explicit 'bounded' constraint on a member (rejects a violating transfer)", () => {
251
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40, minValue: 0, maxValue: 100 });
252
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
253
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
254
+ declareBoundedConstraint({ gameId, resourceId: a.id });
255
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 41 })).toThrow(ConstraintViolationError);
256
+ });
257
+ it("respects a 'monotonic' constraint on a member (rejects a transfer that would decrease an increasing-only resource)", () => {
258
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 40 });
259
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 60 });
260
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id], total: 100 });
261
+ declareMonotonicConstraint({ gameId, resourceId: b.id, direction: "increasing" });
262
+ // Moving grain -> reserve_grain increases b, which is fine...
263
+ expect(() => transferResourceValue({ fromResourceId: b.id, toResourceId: a.id, amount: 5 })).toThrow(ConstraintViolationError);
264
+ expect(getResource(a.id)?.value).toBe(40);
265
+ expect(getResource(b.id)?.value).toBe(60);
266
+ });
267
+ it("a rejected transfer leaves no history rows on either side", () => {
268
+ const { a, b } = makePair(40, 60);
269
+ const outsider = createResource({ gameId, ownerType: "game", name: "treasury", value: 10 });
270
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: outsider.id, amount: 5 })).toThrow();
271
+ expect(getResourceHistory(a.id)).toHaveLength(0);
272
+ expect(getResourceHistory(outsider.id)).toHaveLength(0);
273
+ void b;
274
+ });
275
+ });
276
+ // --------------------------------------------------------------------
277
+ // Atomicity
278
+ // --------------------------------------------------------------------
279
+ describe("transferResourceValue: atomicity", () => {
280
+ it("leaves NO partial write when the second resource's history insert fails mid-operation", () => {
281
+ const { a, b } = makePair(40, 60);
282
+ // Fault injection: fail the resource_history insert specifically for
283
+ // the destination resource, simulating a failure after the source's
284
+ // update (and possibly its history row) has already been applied
285
+ // in-transaction.
286
+ const db = getDatabase();
287
+ db.exec(`
288
+ CREATE TRIGGER fail_history_for_b
289
+ BEFORE INSERT ON resource_history
290
+ WHEN NEW.resource_id = '${b.id}'
291
+ BEGIN
292
+ SELECT RAISE(ABORT, 'simulated failure');
293
+ END;
294
+ `);
295
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15, reason: "trade" })).toThrow();
296
+ // Neither side may have moved -- a partially-applied transfer is
297
+ // exactly the invariant break this feature exists to prevent.
298
+ expect(getResource(a.id)?.value).toBe(40);
299
+ expect(getResource(b.id)?.value).toBe(60);
300
+ expect(getResourceHistory(a.id)).toHaveLength(0);
301
+ expect(getResourceHistory(b.id)).toHaveLength(0);
302
+ });
303
+ it("leaves NO partial write when the second resource's value UPDATE fails mid-operation", () => {
304
+ const { a, b } = makePair(40, 60);
305
+ const db = getDatabase();
306
+ db.exec(`
307
+ CREATE TRIGGER fail_update_for_b
308
+ BEFORE UPDATE ON resources
309
+ WHEN NEW.id = '${b.id}'
310
+ BEGIN
311
+ SELECT RAISE(ABORT, 'simulated failure');
312
+ END;
313
+ `);
314
+ expect(() => transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 })).toThrow();
315
+ expect(getResource(a.id)?.value).toBe(40);
316
+ expect(getResource(b.id)?.value).toBe(60);
317
+ expect(getResourceHistory(a.id)).toHaveLength(0);
318
+ expect(getResourceHistory(b.id)).toHaveLength(0);
319
+ });
320
+ });
321
+ // --------------------------------------------------------------------
322
+ // Nested / re-entrant transactions
323
+ // --------------------------------------------------------------------
324
+ describe("transferResourceValue: nested transaction behavior", () => {
325
+ it("a transfer nested inside an outer withTransaction() that later throws is fully rolled back", () => {
326
+ const { a, b } = makePair(40, 60);
327
+ expect(() => {
328
+ withTransaction(() => {
329
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
330
+ throw new Error("outer failure after transfer");
331
+ });
332
+ }).toThrow("outer failure after transfer");
333
+ expect(getResource(a.id)?.value).toBe(40);
334
+ expect(getResource(b.id)?.value).toBe(60);
335
+ expect(getResourceHistory(a.id)).toHaveLength(0);
336
+ expect(getResourceHistory(b.id)).toHaveLength(0);
337
+ });
338
+ it("a transfer nested inside an outer withTransaction() that succeeds commits normally", () => {
339
+ const { a, b } = makePair(40, 60);
340
+ const outcome = withTransaction(() => {
341
+ return transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
342
+ });
343
+ expect(outcome.from.value).toBe(25);
344
+ expect(getResource(a.id)?.value).toBe(25);
345
+ expect(getResource(b.id)?.value).toBe(75);
346
+ });
347
+ it("chains two transfers inside one outer transaction; if the second fails, BOTH roll back", () => {
348
+ const { a, b } = makePair(40, 60);
349
+ const c = createResource({ gameId, ownerType: "game", name: "population", value: 5 });
350
+ const d = createResource({ gameId, ownerType: "game", name: "casualties", value: 5 });
351
+ declareConservedConstraint({ gameId, resourceIds: [c.id, d.id], total: 10 });
352
+ expect(() => {
353
+ withTransaction(() => {
354
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 15 });
355
+ // Second leg targets an unrelated conserved set incorrectly (a is
356
+ // not a member of it) -- must throw, and must take the first leg
357
+ // down with it since they share the outer transaction.
358
+ transferResourceValue({ fromResourceId: c.id, toResourceId: a.id, amount: 1 });
359
+ });
360
+ }).toThrow(ConstraintViolationError);
361
+ expect(getResource(a.id)?.value).toBe(40);
362
+ expect(getResource(b.id)?.value).toBe(60);
363
+ expect(getResource(c.id)?.value).toBe(5);
364
+ });
365
+ it("chains two valid transfers inside one outer transaction and commits both together", () => {
366
+ const a = createResource({ gameId, ownerType: "game", name: "grain", value: 30 });
367
+ const b = createResource({ gameId, ownerType: "game", name: "reserve_grain", value: 30 });
368
+ const c = createResource({ gameId, ownerType: "game", name: "seed_grain", value: 40 });
369
+ declareConservedConstraint({ gameId, resourceIds: [a.id, b.id, c.id], total: 100 });
370
+ withTransaction(() => {
371
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 10 });
372
+ transferResourceValue({ fromResourceId: b.id, toResourceId: c.id, amount: 20 });
373
+ });
374
+ expect(getResource(a.id)?.value).toBe(20);
375
+ expect(getResource(b.id)?.value).toBe(20);
376
+ expect(getResource(c.id)?.value).toBe(60);
377
+ });
378
+ });
379
+ // --------------------------------------------------------------------
380
+ // Floating point accumulation
381
+ // --------------------------------------------------------------------
382
+ describe("transferResourceValue: floating-point accumulation", () => {
383
+ it("keeps the set within a tiny epsilon of the declared total after many fractional transfers", () => {
384
+ const { a, b } = makePair(100, 0, 100);
385
+ for (let i = 0; i < 1000; i++) {
386
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 0.1 });
387
+ }
388
+ const sum = (getResource(a.id)?.value ?? 0) + (getResource(b.id)?.value ?? 0);
389
+ // Not asserting exact equality -- IEEE 754 doubles cannot represent
390
+ // 0.1 exactly, so some drift after 1000 operations is expected and
391
+ // acceptable. What matters is it stays vanishingly small.
392
+ expect(Math.abs(sum - 100)).toBeLessThan(1e-6);
393
+ });
394
+ it("keeps the set within epsilon after many transfers alternating direction", () => {
395
+ const { a, b } = makePair(50, 50, 100);
396
+ for (let i = 0; i < 500; i++) {
397
+ transferResourceValue({ fromResourceId: a.id, toResourceId: b.id, amount: 0.3 });
398
+ transferResourceValue({ fromResourceId: b.id, toResourceId: a.id, amount: 0.1 });
399
+ }
400
+ const sum = (getResource(a.id)?.value ?? 0) + (getResource(b.id)?.value ?? 0);
401
+ expect(Math.abs(sum - 100)).toBeLessThan(1e-6);
402
+ });
403
+ });
404
+ // --------------------------------------------------------------------
405
+ // deleteResource() protection for conserved members
406
+ // --------------------------------------------------------------------
407
+ describe("deleteResource() on a conserved member", () => {
408
+ it("rejects deleting a resource that is a member of a conserved constraint", () => {
409
+ const { a } = makePair(40, 60);
410
+ expect(() => deleteResource(a.id)).toThrow(ConstraintViolationError);
411
+ expect(getResource(a.id)).not.toBeNull();
412
+ });
413
+ it("rejects deleting either member, not just the first-declared one", () => {
414
+ const { b } = makePair(40, 60);
415
+ expect(() => deleteResource(b.id)).toThrow(ConstraintViolationError);
416
+ expect(getResource(b.id)).not.toBeNull();
417
+ });
418
+ it("after removing the constraint, the former member can be deleted normally", () => {
419
+ const { a, constraint } = makePair(40, 60);
420
+ removeConstraint(constraint.id);
421
+ expect(deleteResource(a.id)).toBe(true);
422
+ expect(getResource(a.id)).toBeNull();
423
+ });
424
+ it("deleting a resource with no conserved constraint works exactly as before", () => {
425
+ const plain = createResource({ gameId, ownerType: "game", name: "treasury", value: 10 });
426
+ expect(deleteResource(plain.id)).toBe(true);
427
+ expect(getResource(plain.id)).toBeNull();
428
+ });
429
+ it("deleting a nonexistent resource still returns false, not a thrown error", () => {
430
+ expect(deleteResource("does-not-exist")).toBe(false);
431
+ });
432
+ });
433
+ // --------------------------------------------------------------------
434
+ // updateResource() protection for conserved members (bounds reclamp)
435
+ // --------------------------------------------------------------------
436
+ describe("updateResource() on a conserved member", () => {
437
+ it("rejects a bounds change that would silently reclamp a conserved member's current value", () => {
438
+ const { a } = makePair(40, 60);
439
+ expect(() => updateResource(a.id, { maxValue: 30 })).toThrow(ConstraintViolationError);
440
+ // Neither the bounds nor the value may have changed.
441
+ const reloaded = getResource(a.id);
442
+ expect(reloaded?.value).toBe(40);
443
+ expect(reloaded?.maxValue).toBeNull();
444
+ });
445
+ it("rejects a minValue change that would silently reclamp a conserved member upward", () => {
446
+ const { a } = makePair(40, 60);
447
+ expect(() => updateResource(a.id, { minValue: 50 })).toThrow(ConstraintViolationError);
448
+ expect(getResource(a.id)?.value).toBe(40);
449
+ });
450
+ it("allows a bounds change on a conserved member that does not affect its current value", () => {
451
+ const { a } = makePair(40, 60);
452
+ const updated = updateResource(a.id, { minValue: 0, maxValue: 1000 });
453
+ expect(updated?.minValue).toBe(0);
454
+ expect(updated?.maxValue).toBe(1000);
455
+ expect(updated?.value).toBe(40);
456
+ });
457
+ it("allows non-bounds metadata changes on a conserved member unconditionally", () => {
458
+ const { a } = makePair(40, 60);
459
+ const updated = updateResource(a.id, { name: "renamed_grain", description: "new desc" });
460
+ expect(updated?.name).toBe("renamed_grain");
461
+ expect(updated?.value).toBe(40);
462
+ });
463
+ it("updateResource on a non-conserved resource still clamps as before", () => {
464
+ const plain = createResource({ gameId, ownerType: "game", name: "population", value: 40 });
465
+ const updated = updateResource(plain.id, { maxValue: 30 });
466
+ expect(updated?.value).toBe(30);
467
+ });
468
+ });
469
+ // --------------------------------------------------------------------
470
+ // Unconstrained resources are completely unaffected
471
+ // --------------------------------------------------------------------
472
+ describe("unconstrained resources are unaffected by any of this", () => {
473
+ it("createResource, updateResourceValue, updateResource, deleteResource all behave as before, even in a game that also has a conserved set", () => {
474
+ makePair(40, 60); // unrelated conserved set exists in this game
475
+ const plain = createResource({ gameId, ownerType: "game", name: "treasury", value: 5, minValue: 0, maxValue: 10 });
476
+ const afterDelta = updateResourceValue({ resourceId: plain.id, mode: "delta", value: 100 });
477
+ expect(afterDelta?.resource.value).toBe(10); // clamped, not rejected
478
+ const afterUpdate = updateResource(plain.id, { maxValue: 3 });
479
+ expect(afterUpdate?.value).toBe(3); // silently reclamped, as always
480
+ expect(deleteResource(plain.id)).toBe(true);
481
+ });
482
+ it("transferResourceValue between two unconstrained resources is rejected (not silently allowed as a generic bank transfer)", () => {
483
+ const x = createResource({ gameId, ownerType: "game", name: "gold", value: 10 });
484
+ const y = createResource({ gameId, ownerType: "game", name: "silver", value: 10 });
485
+ expect(() => transferResourceValue({ fromResourceId: x.id, toResourceId: y.id, amount: 5 })).toThrow(ConstraintViolationError);
486
+ });
487
+ });
488
+ });
@@ -0,0 +1 @@
1
+ export {};