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,426 @@
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 { createResource, getResource, getResourceHistory, deleteResource } from '../resource.js';
6
+ import { createTimer, getTimer, listTimers, tickTimer, resetTimer, modifyTimerState } from '../timers.js';
7
+ describe('timer tools', () => {
8
+ let gameId;
9
+ beforeEach(() => {
10
+ createTestDb();
11
+ gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
12
+ });
13
+ afterEach(() => {
14
+ destroyTestDb();
15
+ });
16
+ describe('tickTimer baseline (no consequence)', () => {
17
+ it('returns null for an unknown timer', () => {
18
+ expect(tickTimer('does-not-exist')).toBeNull();
19
+ });
20
+ it('counts a countdown timer down by the tick amount', () => {
21
+ const timer = createTimer({ gameId, name: 'fuse', timerType: 'countdown', currentValue: 5 });
22
+ const result = tickTimer(timer.id, 1);
23
+ expect(result?.timer.currentValue).toBe(4);
24
+ expect(result?.previousValue).toBe(5);
25
+ });
26
+ it('counts a stopwatch timer up by the tick amount', () => {
27
+ const timer = createTimer({ gameId, name: 'ritual', timerType: 'stopwatch', direction: 'up', maxValue: 10 });
28
+ const result = tickTimer(timer.id, 3);
29
+ expect(result?.timer.currentValue).toBe(3);
30
+ });
31
+ it('flags justTriggered the first time a countdown reaches its triggerAt', () => {
32
+ const timer = createTimer({ gameId, name: 'fuse', timerType: 'countdown', currentValue: 1, triggerAt: 0 });
33
+ const result = tickTimer(timer.id, 1);
34
+ expect(result?.justTriggered).toBe(true);
35
+ expect(result?.timer.triggered).toBe(true);
36
+ });
37
+ it('does not flag justTriggered again on a subsequent tick after already triggered', () => {
38
+ const timer = createTimer({ gameId, name: 'fuse', timerType: 'countdown', currentValue: 1, triggerAt: 0 });
39
+ tickTimer(timer.id, 1);
40
+ const second = tickTimer(timer.id, 1);
41
+ expect(second?.justTriggered).toBe(false);
42
+ });
43
+ });
44
+ describe('createTimer consequence persistence (opt-in field)', () => {
45
+ it('defaults consequence to null when not provided', () => {
46
+ const timer = createTimer({ gameId, name: 'fuse', timerType: 'countdown', currentValue: 3 });
47
+ expect(timer.consequence).toBeNull();
48
+ });
49
+ it('persists and round-trips a provided consequence through createTimer', () => {
50
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
51
+ const timer = createTimer({
52
+ gameId,
53
+ name: 'fuse',
54
+ timerType: 'countdown',
55
+ currentValue: 3,
56
+ consequence: { resourceId: resource.id, delta: -5 },
57
+ });
58
+ expect(timer.consequence).toEqual({ resourceId: resource.id, delta: -5 });
59
+ });
60
+ it('round-trips through getTimer', () => {
61
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
62
+ const created = createTimer({
63
+ gameId,
64
+ name: 'fuse',
65
+ timerType: 'countdown',
66
+ currentValue: 3,
67
+ consequence: { resourceId: resource.id, delta: -5 },
68
+ });
69
+ expect(getTimer(created.id)?.consequence).toEqual({ resourceId: resource.id, delta: -5 });
70
+ });
71
+ it('round-trips through listTimers', () => {
72
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
73
+ createTimer({
74
+ gameId,
75
+ name: 'fuse',
76
+ timerType: 'countdown',
77
+ currentValue: 3,
78
+ consequence: { resourceId: resource.id, delta: -5 },
79
+ });
80
+ const [listed] = listTimers(gameId);
81
+ expect(listed.consequence).toEqual({ resourceId: resource.id, delta: -5 });
82
+ });
83
+ });
84
+ describe('consequences applied by tickTimer -- no LLM in the loop', () => {
85
+ it('applies the consequence the moment the timer crosses its triggerAt', () => {
86
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
87
+ const timer = createTimer({
88
+ gameId,
89
+ name: 'fuse',
90
+ timerType: 'countdown',
91
+ currentValue: 1,
92
+ triggerAt: 0,
93
+ consequence: { resourceId: resource.id, delta: -30 },
94
+ });
95
+ // Nothing reads justTriggered or acts on it -- tickTimer alone must
96
+ // be enough for the resource to change.
97
+ tickTimer(timer.id, 1);
98
+ expect(getResource(resource.id)?.value).toBe(70);
99
+ });
100
+ it('applies a positive delta consequence', () => {
101
+ const resource = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 10 });
102
+ const timer = createTimer({
103
+ gameId,
104
+ name: 'ritual completes',
105
+ timerType: 'stopwatch',
106
+ direction: 'up',
107
+ maxValue: 3,
108
+ triggerAt: 3,
109
+ consequence: { resourceId: resource.id, delta: 25 },
110
+ });
111
+ tickTimer(timer.id, 3);
112
+ expect(getResource(resource.id)?.value).toBe(35);
113
+ });
114
+ it('clamps the consequence delta the same way any other delta update is clamped', () => {
115
+ const resource = createResource({ gameId, ownerType: 'game', name: 'population', value: 5, minValue: 0 });
116
+ const timer = createTimer({
117
+ gameId,
118
+ name: 'plague',
119
+ timerType: 'countdown',
120
+ currentValue: 1,
121
+ triggerAt: 0,
122
+ consequence: { resourceId: resource.id, delta: -100 },
123
+ });
124
+ tickTimer(timer.id, 1);
125
+ expect(getResource(resource.id)?.value).toBe(0);
126
+ });
127
+ it('records a resource_history row whose reason references the triggering timer', () => {
128
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
129
+ const timer = createTimer({
130
+ gameId,
131
+ name: 'fuse',
132
+ timerType: 'countdown',
133
+ currentValue: 1,
134
+ triggerAt: 0,
135
+ consequence: { resourceId: resource.id, delta: -10 },
136
+ });
137
+ tickTimer(timer.id, 1);
138
+ const history = getResourceHistory(resource.id);
139
+ expect(history).toHaveLength(1);
140
+ expect(history[0]).toMatchObject({ previousValue: 100, newValue: 90, delta: -10 });
141
+ expect(history[0].reason).toContain('fuse');
142
+ });
143
+ it('reports consequenceApplied=true on the TickResult when the consequence lands', () => {
144
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
145
+ const timer = createTimer({
146
+ gameId,
147
+ name: 'fuse',
148
+ timerType: 'countdown',
149
+ currentValue: 1,
150
+ triggerAt: 0,
151
+ consequence: { resourceId: resource.id, delta: -10 },
152
+ });
153
+ const result = tickTimer(timer.id, 1);
154
+ expect(result?.consequenceApplied).toBe(true);
155
+ });
156
+ it('reports consequenceApplied=false when a tick does not cross the trigger', () => {
157
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
158
+ const timer = createTimer({
159
+ gameId,
160
+ name: 'fuse',
161
+ timerType: 'countdown',
162
+ currentValue: 5,
163
+ triggerAt: 0,
164
+ consequence: { resourceId: resource.id, delta: -10 },
165
+ });
166
+ const result = tickTimer(timer.id, 1);
167
+ expect(result?.consequenceApplied).toBe(false);
168
+ expect(getResource(resource.id)?.value).toBe(100);
169
+ });
170
+ it('reports consequenceApplied=false when justTriggered fires but no consequence is declared', () => {
171
+ const timer = createTimer({ gameId, name: 'fuse', timerType: 'countdown', currentValue: 1, triggerAt: 0 });
172
+ const result = tickTimer(timer.id, 1);
173
+ expect(result?.justTriggered).toBe(true);
174
+ expect(result?.consequenceApplied).toBe(false);
175
+ });
176
+ it('leaves timers with no declared consequence behaving exactly as before (opt-in)', () => {
177
+ const timer = createTimer({ gameId, name: 'plain fuse', timerType: 'countdown', currentValue: 1, triggerAt: 0 });
178
+ const result = tickTimer(timer.id, 1);
179
+ expect(result?.timer.currentValue).toBe(0);
180
+ expect(result?.timer.triggered).toBe(true);
181
+ });
182
+ });
183
+ describe('exactly-once guarantees', () => {
184
+ it('does not re-apply the consequence on a later tick after the timer already triggered', () => {
185
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
186
+ const timer = createTimer({
187
+ gameId,
188
+ name: 'fuse',
189
+ timerType: 'countdown',
190
+ currentValue: 2,
191
+ triggerAt: 0,
192
+ consequence: { resourceId: resource.id, delta: -10 },
193
+ });
194
+ tickTimer(timer.id, 1); // 2 -> 1, not yet triggered
195
+ tickTimer(timer.id, 1); // 1 -> 0, triggers, applies once
196
+ tickTimer(timer.id, 1); // already triggered, stays at 0 (floor), must not re-apply
197
+ expect(getResource(resource.id)?.value).toBe(90);
198
+ });
199
+ it('applies exactly once even when a single tick amount jumps straight past the trigger boundary', () => {
200
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
201
+ const timer = createTimer({
202
+ gameId,
203
+ name: 'fuse',
204
+ timerType: 'countdown',
205
+ currentValue: 10,
206
+ triggerAt: 5,
207
+ consequence: { resourceId: resource.id, delta: -10 },
208
+ });
209
+ const result = tickTimer(timer.id, 8); // 10 -> 2, jumps past triggerAt=5 in one tick
210
+ expect(result?.justTriggered).toBe(true);
211
+ expect(getResource(resource.id)?.value).toBe(90);
212
+ tickTimer(timer.id, 1); // further ticks must not re-apply
213
+ expect(getResource(resource.id)?.value).toBe(90);
214
+ });
215
+ it('applies a consequence when the new value lands exactly on triggerAt (boundary, countdown)', () => {
216
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
217
+ const timer = createTimer({
218
+ gameId,
219
+ name: 'fuse',
220
+ timerType: 'countdown',
221
+ currentValue: 5,
222
+ triggerAt: 3,
223
+ consequence: { resourceId: resource.id, delta: -10 },
224
+ });
225
+ const result = tickTimer(timer.id, 2); // 5 -> 3, exactly on triggerAt
226
+ expect(result?.justTriggered).toBe(true);
227
+ expect(getResource(resource.id)?.value).toBe(90);
228
+ });
229
+ it('applies a consequence when the new value lands exactly on triggerAt (boundary, stopwatch)', () => {
230
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
231
+ const timer = createTimer({
232
+ gameId,
233
+ name: 'ritual',
234
+ timerType: 'stopwatch',
235
+ direction: 'up',
236
+ maxValue: 6,
237
+ triggerAt: 6,
238
+ consequence: { resourceId: resource.id, delta: -10 },
239
+ });
240
+ const result = tickTimer(timer.id, 6);
241
+ expect(result?.justTriggered).toBe(true);
242
+ expect(getResource(resource.id)?.value).toBe(90);
243
+ });
244
+ it('re-fires the consequence after an explicit reset and a subsequent re-trigger', () => {
245
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
246
+ const timer = createTimer({
247
+ gameId,
248
+ name: 'fuse',
249
+ timerType: 'countdown',
250
+ currentValue: 1,
251
+ triggerAt: 0,
252
+ consequence: { resourceId: resource.id, delta: -10 },
253
+ });
254
+ tickTimer(timer.id, 1); // triggers, -10
255
+ expect(getResource(resource.id)?.value).toBe(90);
256
+ resetTimer(timer.id); // explicit reset clears the triggered flag
257
+ tickTimer(timer.id, 1); // fires again -- this is a deliberate re-arm, not a double-fire of the same event
258
+ expect(getResource(resource.id)?.value).toBe(80);
259
+ });
260
+ it('resetTimer itself never applies a consequence, even though it clears the triggered flag', () => {
261
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
262
+ const timer = createTimer({
263
+ gameId,
264
+ name: 'fuse',
265
+ timerType: 'countdown',
266
+ currentValue: 1,
267
+ triggerAt: 0,
268
+ consequence: { resourceId: resource.id, delta: -10 },
269
+ });
270
+ tickTimer(timer.id, 1);
271
+ expect(getResource(resource.id)?.value).toBe(90);
272
+ resetTimer(timer.id);
273
+ expect(getResource(resource.id)?.value).toBe(90); // unchanged by reset alone
274
+ });
275
+ it('modifyTimerState({mode: "reset"}) does not apply a consequence', () => {
276
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
277
+ const timer = createTimer({
278
+ gameId,
279
+ name: 'fuse',
280
+ timerType: 'countdown',
281
+ currentValue: 1,
282
+ triggerAt: 0,
283
+ consequence: { resourceId: resource.id, delta: -10 },
284
+ });
285
+ tickTimer(timer.id, 1);
286
+ modifyTimerState(timer.id, { mode: 'reset' });
287
+ expect(getResource(resource.id)?.value).toBe(90);
288
+ });
289
+ it('modifyTimerState({mode: "tick"}) applies the consequence exactly like tickTimer', () => {
290
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
291
+ const timer = createTimer({
292
+ gameId,
293
+ name: 'fuse',
294
+ timerType: 'countdown',
295
+ currentValue: 1,
296
+ triggerAt: 0,
297
+ consequence: { resourceId: resource.id, delta: -10 },
298
+ });
299
+ const result = modifyTimerState(timer.id, { mode: 'tick', amount: 1 });
300
+ expect(result?.consequenceApplied).toBe(true);
301
+ expect(getResource(resource.id)?.value).toBe(90);
302
+ });
303
+ });
304
+ describe('a consequence that itself fails', () => {
305
+ it('throws when the target resource never existed, and does not tick the timer at all', () => {
306
+ const timer = createTimer({
307
+ gameId,
308
+ name: 'fuse',
309
+ timerType: 'countdown',
310
+ currentValue: 1,
311
+ triggerAt: 0,
312
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
313
+ });
314
+ expect(() => tickTimer(timer.id, 1)).toThrow();
315
+ const reloaded = getTimer(timer.id);
316
+ expect(reloaded?.currentValue).toBe(1);
317
+ expect(reloaded?.triggered).toBe(false);
318
+ });
319
+ it('throws when the target resource was deleted after the timer was created', () => {
320
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
321
+ const timer = createTimer({
322
+ gameId,
323
+ name: 'fuse',
324
+ timerType: 'countdown',
325
+ currentValue: 1,
326
+ triggerAt: 0,
327
+ consequence: { resourceId: resource.id, delta: -10 },
328
+ });
329
+ deleteResource(resource.id);
330
+ expect(() => tickTimer(timer.id, 1)).toThrow();
331
+ const reloaded = getTimer(timer.id);
332
+ expect(reloaded?.currentValue).toBe(1);
333
+ expect(reloaded?.triggered).toBe(false);
334
+ });
335
+ it('rolls back the value and triggered-flag change together with the consequence on a mid-transaction failure', () => {
336
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
337
+ const timer = createTimer({
338
+ gameId,
339
+ name: 'fuse',
340
+ timerType: 'countdown',
341
+ currentValue: 1,
342
+ triggerAt: 0,
343
+ consequence: { resourceId: resource.id, delta: -10 },
344
+ });
345
+ const db = getDatabase();
346
+ db.exec(`
347
+ CREATE TRIGGER fail_resource_history_insert_timer
348
+ BEFORE INSERT ON resource_history
349
+ BEGIN
350
+ SELECT RAISE(ABORT, 'simulated failure');
351
+ END;
352
+ `);
353
+ expect(() => tickTimer(timer.id, 1)).toThrow();
354
+ expect(getResource(resource.id)?.value).toBe(100);
355
+ expect(getResourceHistory(resource.id)).toHaveLength(0);
356
+ const reloaded = getTimer(timer.id);
357
+ expect(reloaded?.currentValue).toBe(1);
358
+ expect(reloaded?.triggered).toBe(false);
359
+ });
360
+ it('permits ticking again after fixing the underlying resource problem', () => {
361
+ const timer = createTimer({
362
+ gameId,
363
+ name: 'fuse',
364
+ timerType: 'countdown',
365
+ currentValue: 1,
366
+ triggerAt: 0,
367
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
368
+ });
369
+ expect(() => tickTimer(timer.id, 1)).toThrow();
370
+ // Retrying with the same broken consequence keeps failing safely --
371
+ // it never silently "succeeds" by skipping the consequence.
372
+ expect(() => tickTimer(timer.id, 1)).toThrow();
373
+ const reloaded = getTimer(timer.id);
374
+ expect(reloaded?.currentValue).toBe(1);
375
+ expect(reloaded?.triggered).toBe(false);
376
+ });
377
+ });
378
+ describe('interleaving with unrelated timers', () => {
379
+ it('applies consequences independently across multiple timers sharing no state', () => {
380
+ const grain = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
381
+ const treasury = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
382
+ const fuseA = createTimer({
383
+ gameId,
384
+ name: 'fuse A',
385
+ timerType: 'countdown',
386
+ currentValue: 1,
387
+ triggerAt: 0,
388
+ consequence: { resourceId: grain.id, delta: -10 },
389
+ });
390
+ const fuseB = createTimer({
391
+ gameId,
392
+ name: 'fuse B',
393
+ timerType: 'countdown',
394
+ currentValue: 1,
395
+ triggerAt: 0,
396
+ consequence: { resourceId: treasury.id, delta: -25 },
397
+ });
398
+ tickTimer(fuseA.id, 1);
399
+ tickTimer(fuseB.id, 1);
400
+ expect(getResource(grain.id)?.value).toBe(90);
401
+ expect(getResource(treasury.id)?.value).toBe(75);
402
+ });
403
+ it('one timer failing to apply its consequence does not affect an unrelated timer ticked afterward', () => {
404
+ const treasury = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
405
+ const brokenFuse = createTimer({
406
+ gameId,
407
+ name: 'broken fuse',
408
+ timerType: 'countdown',
409
+ currentValue: 1,
410
+ triggerAt: 0,
411
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
412
+ });
413
+ const healthyFuse = createTimer({
414
+ gameId,
415
+ name: 'healthy fuse',
416
+ timerType: 'countdown',
417
+ currentValue: 1,
418
+ triggerAt: 0,
419
+ consequence: { resourceId: treasury.id, delta: -25 },
420
+ });
421
+ expect(() => tickTimer(brokenFuse.id, 1)).toThrow();
422
+ tickTimer(healthyFuse.id, 1);
423
+ expect(getResource(treasury.id)?.value).toBe(75);
424
+ });
425
+ });
426
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
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 { connectLocations, createLocation, getLocation } from '../world.js';
6
+ describe('connectLocations', () => {
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('adds a one-way exit when bidirectional is false', () => {
16
+ const a = createLocation({ gameId, name: 'Field', description: 'An open field' });
17
+ const b = createLocation({ gameId, name: 'Barn', description: 'A wooden barn' });
18
+ const result = connectLocations({
19
+ fromLocationId: a.id,
20
+ toLocationId: b.id,
21
+ fromDirection: 'north',
22
+ toDirection: 'south',
23
+ bidirectional: false,
24
+ });
25
+ expect(result?.success).toBe(true);
26
+ expect(result?.reverseExitCreated).toBeNull();
27
+ expect(getLocation(a.id)?.properties.exits).toHaveLength(1);
28
+ expect(getLocation(b.id)?.properties.exits).toHaveLength(0);
29
+ });
30
+ it('adds exits to both locations when bidirectional', () => {
31
+ const a = createLocation({ gameId, name: 'Field', description: 'An open field' });
32
+ const b = createLocation({ gameId, name: 'Barn', description: 'A wooden barn' });
33
+ connectLocations({
34
+ fromLocationId: a.id,
35
+ toLocationId: b.id,
36
+ fromDirection: 'north',
37
+ toDirection: 'south',
38
+ });
39
+ expect(getLocation(a.id)?.properties.exits).toHaveLength(1);
40
+ expect(getLocation(b.id)?.properties.exits).toHaveLength(1);
41
+ });
42
+ it('leaves NO partial write when the second (reverse-exit) update fails mid-operation', () => {
43
+ const a = createLocation({ gameId, name: 'Field', description: 'An open field' });
44
+ const b = createLocation({ gameId, name: 'Barn', description: 'A wooden barn' });
45
+ // Fault injection: make the UPDATE against location `b` (the second
46
+ // write connectLocations performs) fail, simulating a mid-operation
47
+ // failure such as a disk error or constraint violation. If the two
48
+ // writes aren't wrapped in a real transaction, location `a` will have
49
+ // already been updated by the time this trigger fires.
50
+ const db = getDatabase();
51
+ db.exec(`
52
+ CREATE TRIGGER fail_second_update
53
+ BEFORE UPDATE ON locations
54
+ WHEN NEW.id = '${b.id}'
55
+ BEGIN
56
+ SELECT RAISE(ABORT, 'simulated failure');
57
+ END;
58
+ `);
59
+ expect(() => connectLocations({
60
+ fromLocationId: a.id,
61
+ toLocationId: b.id,
62
+ fromDirection: 'north',
63
+ toDirection: 'south',
64
+ })).toThrow();
65
+ // Neither location should show any exit -- the forward-exit write to
66
+ // `a` must have been rolled back along with the failed write to `b`.
67
+ expect(getLocation(a.id)?.properties.exits).toHaveLength(0);
68
+ expect(getLocation(b.id)?.properties.exits).toHaveLength(0);
69
+ });
70
+ });
@@ -0,0 +1,48 @@
1
+ import type { Ability } from "../types/index.js";
2
+ export declare function createAbility(params: {
3
+ gameId: string;
4
+ ownerType: "template" | "character";
5
+ ownerId?: string;
6
+ name: string;
7
+ description?: string;
8
+ category?: string;
9
+ cost?: Record<string, number>;
10
+ cooldown?: number;
11
+ effects?: string[];
12
+ requirements?: Record<string, number>;
13
+ tags?: string[];
14
+ }): Ability;
15
+ export declare function getAbility(id: string): Ability | null;
16
+ export declare function updateAbility(id: string, updates: {
17
+ name?: string;
18
+ description?: string;
19
+ category?: string | null;
20
+ cost?: Record<string, number>;
21
+ cooldown?: number | null;
22
+ effects?: string[];
23
+ requirements?: Record<string, number>;
24
+ tags?: string[];
25
+ }): Ability | null;
26
+ export declare function deleteAbility(id: string): boolean;
27
+ export declare function listAbilities(gameId: string, filter?: {
28
+ ownerType?: "template" | "character";
29
+ ownerId?: string;
30
+ category?: string;
31
+ }): Ability[];
32
+ export declare function learnAbility(templateId: string, characterId: string): Ability | null;
33
+ export interface UseAbilityResult {
34
+ success: boolean;
35
+ ability: Ability;
36
+ reason?: string;
37
+ costsPaid?: Record<string, number>;
38
+ }
39
+ export declare function useAbility(abilityId: string, _characterId: string): UseAbilityResult;
40
+ export declare function tickCooldowns(gameId: string, amount?: number): Ability[];
41
+ export interface RequirementCheck {
42
+ meetsRequirements: boolean;
43
+ missing: Record<string, {
44
+ required: number;
45
+ actual: number;
46
+ }>;
47
+ }
48
+ export declare function checkRequirements(abilityId: string, characterId: string): RequirementCheck | null;