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,190 @@
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, updateResourceValue } from '../resource.js';
6
+ describe('updateResourceValue', () => {
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('returns null when the resource does not exist', () => {
16
+ const result = updateResourceValue({ resourceId: 'does-not-exist', mode: 'delta', value: 5 });
17
+ expect(result).toBeNull();
18
+ });
19
+ describe('delta mode', () => {
20
+ it('adds the delta to the current value', () => {
21
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
22
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 5 });
23
+ expect(result).not.toBeNull();
24
+ expect(result?.resource.value).toBe(15);
25
+ });
26
+ it('subtracts when the delta is negative', () => {
27
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
28
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'delta', value: -4 });
29
+ expect(result?.resource.value).toBe(6);
30
+ });
31
+ it('persists the new value to the database', () => {
32
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
33
+ updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 5 });
34
+ const reloaded = getResource(resource.id);
35
+ expect(reloaded?.value).toBe(15);
36
+ });
37
+ });
38
+ describe('set mode', () => {
39
+ it('replaces the current value entirely, ignoring the previous value', () => {
40
+ const resource = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
41
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'set', value: 42 });
42
+ expect(result?.resource.value).toBe(42);
43
+ });
44
+ });
45
+ describe('clamping', () => {
46
+ it('clamps delta results to max_value', () => {
47
+ const resource = createResource({
48
+ gameId,
49
+ ownerType: 'game',
50
+ name: 'population',
51
+ value: 90,
52
+ maxValue: 100,
53
+ });
54
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 50 });
55
+ expect(result?.resource.value).toBe(100);
56
+ });
57
+ it('clamps delta results to min_value', () => {
58
+ const resource = createResource({
59
+ gameId,
60
+ ownerType: 'game',
61
+ name: 'population',
62
+ value: 10,
63
+ minValue: 0,
64
+ });
65
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'delta', value: -50 });
66
+ expect(result?.resource.value).toBe(0);
67
+ });
68
+ it('clamps set-mode values to max_value', () => {
69
+ const resource = createResource({
70
+ gameId,
71
+ ownerType: 'game',
72
+ name: 'population',
73
+ value: 10,
74
+ maxValue: 100,
75
+ });
76
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'set', value: 500 });
77
+ expect(result?.resource.value).toBe(100);
78
+ });
79
+ it('clamps set-mode values to min_value', () => {
80
+ const resource = createResource({
81
+ gameId,
82
+ ownerType: 'game',
83
+ name: 'population',
84
+ value: 10,
85
+ minValue: 0,
86
+ });
87
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'set', value: -500 });
88
+ expect(result?.resource.value).toBe(0);
89
+ });
90
+ it('does not clamp when no bounds are set', () => {
91
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
92
+ const result = updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 1000000 });
93
+ expect(result?.resource.value).toBe(1000010);
94
+ });
95
+ });
96
+ describe('resource_history rows', () => {
97
+ it('records previous_value, new_value, delta, and reason for a delta update', () => {
98
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
99
+ const result = updateResourceValue({
100
+ resourceId: resource.id,
101
+ mode: 'delta',
102
+ value: 5,
103
+ reason: 'harvest',
104
+ });
105
+ expect(result?.change).toMatchObject({
106
+ resourceId: resource.id,
107
+ previousValue: 10,
108
+ newValue: 15,
109
+ delta: 5,
110
+ reason: 'harvest',
111
+ });
112
+ const history = getResourceHistory(resource.id);
113
+ expect(history).toHaveLength(1);
114
+ expect(history[0]).toMatchObject({
115
+ resourceId: resource.id,
116
+ previousValue: 10,
117
+ newValue: 15,
118
+ delta: 5,
119
+ reason: 'harvest',
120
+ });
121
+ });
122
+ it('records the post-clamp delta, not the requested delta', () => {
123
+ const resource = createResource({
124
+ gameId,
125
+ ownerType: 'game',
126
+ name: 'population',
127
+ value: 90,
128
+ maxValue: 100,
129
+ });
130
+ updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 50, reason: 'growth' });
131
+ const history = getResourceHistory(resource.id);
132
+ expect(history[0]).toMatchObject({
133
+ previousValue: 90,
134
+ newValue: 100,
135
+ delta: 10,
136
+ });
137
+ });
138
+ it('records a history row for a set-mode update, with delta as the difference', () => {
139
+ const resource = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 100 });
140
+ updateResourceValue({ resourceId: resource.id, mode: 'set', value: 42, reason: 'audit correction' });
141
+ const history = getResourceHistory(resource.id);
142
+ expect(history[0]).toMatchObject({
143
+ previousValue: 100,
144
+ newValue: 42,
145
+ delta: -58,
146
+ reason: 'audit correction',
147
+ });
148
+ });
149
+ it('defaults reason to null when not provided', () => {
150
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
151
+ updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 1 });
152
+ const history = getResourceHistory(resource.id);
153
+ expect(history[0].reason).toBeNull();
154
+ });
155
+ it('appends multiple history rows across repeated updates', () => {
156
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 0 });
157
+ updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 5, reason: 'first' });
158
+ updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 3, reason: 'second' });
159
+ const history = getResourceHistory(resource.id);
160
+ expect(history).toHaveLength(2);
161
+ // Both updates can land in the same millisecond, so don't assume a
162
+ // strict order between same-timestamp rows -- just check both exist.
163
+ expect(history).toEqual(expect.arrayContaining([
164
+ expect.objectContaining({ previousValue: 0, newValue: 5, delta: 5, reason: 'first' }),
165
+ expect.objectContaining({ previousValue: 5, newValue: 8, delta: 3, reason: 'second' }),
166
+ ]));
167
+ });
168
+ });
169
+ describe('atomicity', () => {
170
+ it('leaves NO partial write when the resource_history insert fails mid-operation', () => {
171
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 10 });
172
+ // Fault injection: make the resource_history INSERT fail after the
173
+ // resources UPDATE has already run, simulating a mid-operation
174
+ // failure such as a disk error or constraint violation.
175
+ const db = getDatabase();
176
+ db.exec(`
177
+ CREATE TRIGGER fail_resource_history_insert
178
+ BEFORE INSERT ON resource_history
179
+ BEGIN
180
+ SELECT RAISE(ABORT, 'simulated failure');
181
+ END;
182
+ `);
183
+ expect(() => updateResourceValue({ resourceId: resource.id, mode: 'delta', value: 5, reason: 'harvest' })).toThrow();
184
+ // The resources.value write must have been rolled back along with
185
+ // the failed resource_history write.
186
+ expect(getResource(resource.id)?.value).toBe(10);
187
+ expect(getResourceHistory(resource.id)).toHaveLength(0);
188
+ });
189
+ });
190
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,404 @@
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 { setCalendar, advanceTime, scheduleEvent, listScheduledEvents, getTime } from '../time.js';
7
+ // Fixed calendar: 30 days/month, 24h/day, so hour arithmetic is easy to
8
+ // reason about. All tests start the clock at year 1, month 0, day 0, 08:00.
9
+ const dt = (overrides = {}) => ({
10
+ year: 1,
11
+ month: 0,
12
+ day: 0,
13
+ hour: 8,
14
+ minute: 0,
15
+ ...overrides,
16
+ });
17
+ describe('time tools', () => {
18
+ let gameId;
19
+ beforeEach(() => {
20
+ createTestDb();
21
+ gameId = createGame({ name: 'Test Game', setting: 'Test Setting', style: 'Test Style' }).id;
22
+ setCalendar(gameId, {});
23
+ });
24
+ afterEach(() => {
25
+ destroyTestDb();
26
+ });
27
+ describe('advanceTime baseline (no consequences involved)', () => {
28
+ it('returns null for a game with no calendar set', () => {
29
+ expect(advanceTime('does-not-exist', { hours: 1 })).toBeNull();
30
+ });
31
+ it('advances the clock by the given duration', () => {
32
+ const result = advanceTime(gameId, { hours: 2 });
33
+ expect(result?.newTime).toMatchObject({ hour: 10 });
34
+ });
35
+ it('persists the new time', () => {
36
+ advanceTime(gameId, { hours: 2 });
37
+ expect(getTime(gameId)?.currentTime).toMatchObject({ hour: 10 });
38
+ });
39
+ it('triggers a scheduled event whose trigger time falls within the advanced window', () => {
40
+ scheduleEvent({ gameId, name: 'a bell tolls', triggerTime: dt({ hour: 9 }) });
41
+ const result = advanceTime(gameId, { hours: 2 });
42
+ expect(result?.triggeredEvents).toHaveLength(1);
43
+ expect(result?.triggeredEvents[0].name).toBe('a bell tolls');
44
+ });
45
+ it('does not trigger an event whose trigger time is beyond the advanced window', () => {
46
+ scheduleEvent({ gameId, name: 'a bell tolls', triggerTime: dt({ hour: 23 }) });
47
+ const result = advanceTime(gameId, { hours: 2 });
48
+ expect(result?.triggeredEvents).toHaveLength(0);
49
+ });
50
+ it('returns an empty consequenceFailures array when nothing fails', () => {
51
+ const result = advanceTime(gameId, { hours: 1 });
52
+ expect(result?.consequenceFailures).toEqual([]);
53
+ });
54
+ it('does not trigger anything when advancing by zero time', () => {
55
+ scheduleEvent({ gameId, name: 'a bell tolls', triggerTime: dt({ hour: 8 }) });
56
+ // Passing an empty duration object advances by 0 minutes.
57
+ const result = advanceTime(gameId, {});
58
+ // hour 8 is both previousTime and newTime here, so the boundary check
59
+ // still matches -- this asserts "not moving" doesn't change that.
60
+ expect(result?.newTime).toEqual(result?.previousTime);
61
+ });
62
+ });
63
+ describe('scheduleEvent consequence persistence (opt-in field)', () => {
64
+ it('defaults consequence to null when not provided', () => {
65
+ const event = scheduleEvent({ gameId, name: 'plain event', triggerTime: dt() });
66
+ expect(event.consequence).toBeNull();
67
+ });
68
+ it('persists and round-trips a provided consequence through scheduleEvent', () => {
69
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 5 });
70
+ const event = scheduleEvent({
71
+ gameId,
72
+ name: 'spoilage',
73
+ triggerTime: dt(),
74
+ consequence: { resourceId: resource.id, delta: -1 },
75
+ });
76
+ expect(event.consequence).toEqual({ resourceId: resource.id, delta: -1 });
77
+ });
78
+ it('round-trips a provided consequence through listScheduledEvents', () => {
79
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 5 });
80
+ scheduleEvent({
81
+ gameId,
82
+ name: 'spoilage',
83
+ triggerTime: dt(),
84
+ consequence: { resourceId: resource.id, delta: -1 },
85
+ });
86
+ const [listed] = listScheduledEvents(gameId, true);
87
+ expect(listed.consequence).toEqual({ resourceId: resource.id, delta: -1 });
88
+ });
89
+ it('null consequence round-trips through listScheduledEvents too', () => {
90
+ scheduleEvent({ gameId, name: 'plain event', triggerTime: dt() });
91
+ const [listed] = listScheduledEvents(gameId, true);
92
+ expect(listed.consequence).toBeNull();
93
+ });
94
+ });
95
+ describe('consequences applied by advanceTime -- no LLM in the loop', () => {
96
+ it('applies a declared consequence to the target resource the moment the event triggers', () => {
97
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
98
+ scheduleEvent({
99
+ gameId,
100
+ name: 'spoilage',
101
+ triggerTime: dt({ hour: 9 }),
102
+ consequence: { resourceId: resource.id, delta: -20 },
103
+ });
104
+ // Nothing reads triggeredEvents or acts on it -- advanceTime alone
105
+ // must be enough for the resource to change.
106
+ advanceTime(gameId, { hours: 2 });
107
+ expect(getResource(resource.id)?.value).toBe(80);
108
+ });
109
+ it('applies a positive delta consequence', () => {
110
+ const resource = createResource({ gameId, ownerType: 'game', name: 'treasury', value: 10 });
111
+ scheduleEvent({
112
+ gameId,
113
+ name: 'windfall',
114
+ triggerTime: dt({ hour: 9 }),
115
+ consequence: { resourceId: resource.id, delta: 15 },
116
+ });
117
+ advanceTime(gameId, { hours: 2 });
118
+ expect(getResource(resource.id)?.value).toBe(25);
119
+ });
120
+ it('clamps the consequence delta the same way any other delta update is clamped', () => {
121
+ const resource = createResource({
122
+ gameId,
123
+ ownerType: 'game',
124
+ name: 'population',
125
+ value: 5,
126
+ minValue: 0,
127
+ });
128
+ scheduleEvent({
129
+ gameId,
130
+ name: 'famine',
131
+ triggerTime: dt({ hour: 9 }),
132
+ consequence: { resourceId: resource.id, delta: -100 },
133
+ });
134
+ advanceTime(gameId, { hours: 2 });
135
+ expect(getResource(resource.id)?.value).toBe(0);
136
+ });
137
+ it('records a resource_history row whose reason references the triggering event', () => {
138
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
139
+ scheduleEvent({
140
+ gameId,
141
+ name: 'spoilage',
142
+ triggerTime: dt({ hour: 9 }),
143
+ consequence: { resourceId: resource.id, delta: -10 },
144
+ });
145
+ advanceTime(gameId, { hours: 2 });
146
+ const history = getResourceHistory(resource.id);
147
+ expect(history).toHaveLength(1);
148
+ expect(history[0]).toMatchObject({ previousValue: 100, newValue: 90, delta: -10 });
149
+ expect(history[0].reason).toContain('spoilage');
150
+ });
151
+ it('includes the triggered event with its consequence in the returned triggeredEvents', () => {
152
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
153
+ scheduleEvent({
154
+ gameId,
155
+ name: 'spoilage',
156
+ triggerTime: dt({ hour: 9 }),
157
+ consequence: { resourceId: resource.id, delta: -10 },
158
+ });
159
+ const result = advanceTime(gameId, { hours: 2 });
160
+ expect(result?.triggeredEvents[0].consequence).toEqual({ resourceId: resource.id, delta: -10 });
161
+ });
162
+ it('leaves events with no declared consequence behaving exactly as before (opt-in)', () => {
163
+ scheduleEvent({ gameId, name: 'plain event', triggerTime: dt({ hour: 9 }) });
164
+ const result = advanceTime(gameId, { hours: 2 });
165
+ expect(result?.triggeredEvents).toHaveLength(1);
166
+ expect(result?.consequenceFailures).toEqual([]);
167
+ });
168
+ });
169
+ describe('exactly-once guarantees', () => {
170
+ it('does not re-apply the consequence when advanceTime is called again afterward', () => {
171
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
172
+ scheduleEvent({
173
+ gameId,
174
+ name: 'spoilage',
175
+ triggerTime: dt({ hour: 9 }),
176
+ consequence: { resourceId: resource.id, delta: -20 },
177
+ });
178
+ advanceTime(gameId, { hours: 2 }); // 08:00 -> 10:00, crosses 09:00, triggers
179
+ advanceTime(gameId, { hours: 2 }); // 10:00 -> 12:00, event already triggered
180
+ expect(getResource(resource.id)?.value).toBe(80);
181
+ });
182
+ it('marks a fired non-recurring event triggered so it is excluded from future pending lists', () => {
183
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
184
+ scheduleEvent({
185
+ gameId,
186
+ name: 'spoilage',
187
+ triggerTime: dt({ hour: 9 }),
188
+ consequence: { resourceId: resource.id, delta: -20 },
189
+ });
190
+ advanceTime(gameId, { hours: 2 });
191
+ expect(listScheduledEvents(gameId, false)).toHaveLength(0);
192
+ expect(listScheduledEvents(gameId, true)).toHaveLength(1);
193
+ });
194
+ it('applies consequences for multiple events that all fall within a single large time jump, each exactly once', () => {
195
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
196
+ scheduleEvent({
197
+ gameId,
198
+ name: 'first spoilage',
199
+ triggerTime: dt({ hour: 9 }),
200
+ consequence: { resourceId: resource.id, delta: -10 },
201
+ });
202
+ scheduleEvent({
203
+ gameId,
204
+ name: 'second spoilage',
205
+ triggerTime: dt({ hour: 10 }),
206
+ consequence: { resourceId: resource.id, delta: -5 },
207
+ });
208
+ scheduleEvent({
209
+ gameId,
210
+ name: 'third spoilage',
211
+ triggerTime: dt({ hour: 11 }),
212
+ consequence: { resourceId: resource.id, delta: -1 },
213
+ });
214
+ const result = advanceTime(gameId, { hours: 5 }); // 08:00 -> 13:00
215
+ expect(result?.triggeredEvents).toHaveLength(3);
216
+ expect(getResource(resource.id)?.value).toBe(84);
217
+ });
218
+ it('applies a consequence when the trigger time lands exactly on the new-time boundary (inclusive)', () => {
219
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
220
+ scheduleEvent({
221
+ gameId,
222
+ name: 'spoilage',
223
+ triggerTime: dt({ hour: 10 }),
224
+ consequence: { resourceId: resource.id, delta: -10 },
225
+ });
226
+ const result = advanceTime(gameId, { hours: 2 }); // 08:00 -> 10:00 exactly
227
+ expect(result?.triggeredEvents).toHaveLength(1);
228
+ expect(getResource(resource.id)?.value).toBe(90);
229
+ });
230
+ it('applies a consequence when the trigger time lands exactly on the previous-time boundary (inclusive)', () => {
231
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
232
+ scheduleEvent({
233
+ gameId,
234
+ name: 'spoilage',
235
+ triggerTime: dt({ hour: 8 }),
236
+ consequence: { resourceId: resource.id, delta: -10 },
237
+ });
238
+ const result = advanceTime(gameId, { hours: 2 }); // starts exactly at 08:00
239
+ expect(result?.triggeredEvents).toHaveLength(1);
240
+ expect(getResource(resource.id)?.value).toBe(90);
241
+ });
242
+ it('does not re-apply an already-triggered consequence when time subsequently moves backward over it', () => {
243
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
244
+ scheduleEvent({
245
+ gameId,
246
+ name: 'spoilage',
247
+ triggerTime: dt({ hour: 9 }),
248
+ consequence: { resourceId: resource.id, delta: -10 },
249
+ });
250
+ advanceTime(gameId, { hours: 3 }); // 08:00 -> 11:00, triggers once
251
+ expect(getResource(resource.id)?.value).toBe(90);
252
+ advanceTime(gameId, { hours: -2 }); // 11:00 -> 09:00, moving back over the same trigger time
253
+ expect(getResource(resource.id)?.value).toBe(90);
254
+ });
255
+ it('re-applies a recurring consequence exactly once per occurrence, and reschedules the trigger', () => {
256
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
257
+ scheduleEvent({
258
+ gameId,
259
+ name: 'daily toll',
260
+ triggerTime: dt({ hour: 9 }),
261
+ recurring: 'daily',
262
+ consequence: { resourceId: resource.id, delta: -5 },
263
+ });
264
+ advanceTime(gameId, { hours: 2 }); // 08:00 -> 10:00, crosses the daily trigger once
265
+ expect(getResource(resource.id)?.value).toBe(95);
266
+ const pending = listScheduledEvents(gameId, false);
267
+ expect(pending).toHaveLength(1);
268
+ expect(pending[0].triggerTime.day).toBe(1); // rescheduled a day forward
269
+ advanceTime(gameId, { days: 1 }); // crosses the rescheduled occurrence
270
+ expect(getResource(resource.id)?.value).toBe(90);
271
+ advanceTime(gameId, { days: 1 }); // crosses a third occurrence
272
+ expect(getResource(resource.id)?.value).toBe(85);
273
+ });
274
+ it('a recurring event never sets triggered=1 -- it stays perpetually pending between occurrences', () => {
275
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
276
+ scheduleEvent({
277
+ gameId,
278
+ name: 'daily toll',
279
+ triggerTime: dt({ hour: 9 }),
280
+ recurring: 'daily',
281
+ consequence: { resourceId: resource.id, delta: -5 },
282
+ });
283
+ advanceTime(gameId, { hours: 2 });
284
+ const [event] = listScheduledEvents(gameId, false);
285
+ expect(event.triggered).toBe(false);
286
+ });
287
+ });
288
+ describe('a consequence that itself fails', () => {
289
+ it('does not mark the event triggered when the target resource never existed, and reports the failure', () => {
290
+ scheduleEvent({
291
+ gameId,
292
+ name: 'broken spoilage',
293
+ triggerTime: dt({ hour: 9 }),
294
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
295
+ });
296
+ const result = advanceTime(gameId, { hours: 2 });
297
+ expect(result?.triggeredEvents).toHaveLength(0);
298
+ expect(result?.consequenceFailures).toHaveLength(1);
299
+ expect(result?.consequenceFailures[0]).toMatchObject({
300
+ eventName: 'broken spoilage',
301
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
302
+ });
303
+ expect(typeof result?.consequenceFailures[0].error).toBe('string');
304
+ });
305
+ it('leaves the event pending (not triggered) after a failed consequence -- no expired-but-unapplied state', () => {
306
+ scheduleEvent({
307
+ gameId,
308
+ name: 'broken spoilage',
309
+ triggerTime: dt({ hour: 9 }),
310
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
311
+ });
312
+ advanceTime(gameId, { hours: 2 });
313
+ // Still pending: the event never got marked triggered, so it shows up
314
+ // in the "not yet triggered" list, and it's the only event that
315
+ // exists at all (hence also length 1 with includeTriggered=true).
316
+ expect(listScheduledEvents(gameId, false)).toHaveLength(1);
317
+ expect(listScheduledEvents(gameId, true)).toHaveLength(1);
318
+ expect(listScheduledEvents(gameId, false)[0].triggered).toBe(false);
319
+ });
320
+ it('does not mark the event triggered when the target resource was deleted after scheduling', () => {
321
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
322
+ scheduleEvent({
323
+ gameId,
324
+ name: 'spoilage',
325
+ triggerTime: dt({ hour: 9 }),
326
+ consequence: { resourceId: resource.id, delta: -10 },
327
+ });
328
+ deleteResource(resource.id);
329
+ const result = advanceTime(gameId, { hours: 2 });
330
+ expect(result?.triggeredEvents).toHaveLength(0);
331
+ expect(result?.consequenceFailures).toHaveLength(1);
332
+ expect(listScheduledEvents(gameId, false)).toHaveLength(1);
333
+ });
334
+ it('does not let a failing event block other, unrelated events in the same advanceTime call', () => {
335
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
336
+ scheduleEvent({
337
+ gameId,
338
+ name: 'broken spoilage',
339
+ triggerTime: dt({ hour: 9 }),
340
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
341
+ });
342
+ scheduleEvent({
343
+ gameId,
344
+ name: 'healthy spoilage',
345
+ triggerTime: dt({ hour: 9, minute: 30 }),
346
+ consequence: { resourceId: resource.id, delta: -10 },
347
+ });
348
+ const result = advanceTime(gameId, { hours: 2 });
349
+ expect(result?.triggeredEvents).toHaveLength(1);
350
+ expect(result?.triggeredEvents[0].name).toBe('healthy spoilage');
351
+ expect(result?.consequenceFailures).toHaveLength(1);
352
+ expect(getResource(resource.id)?.value).toBe(90);
353
+ });
354
+ it('still advances game time even when a consequence fails', () => {
355
+ scheduleEvent({
356
+ gameId,
357
+ name: 'broken spoilage',
358
+ triggerTime: dt({ hour: 9 }),
359
+ consequence: { resourceId: 'does-not-exist', delta: -10 },
360
+ });
361
+ const result = advanceTime(gameId, { hours: 2 });
362
+ expect(result?.newTime).toMatchObject({ hour: 10 });
363
+ expect(getTime(gameId)?.currentTime).toMatchObject({ hour: 10 });
364
+ });
365
+ it('rolls back the event trigger flag together with the consequence when the resource_history write fails mid-transaction', () => {
366
+ const resource = createResource({ gameId, ownerType: 'game', name: 'grain', value: 100 });
367
+ scheduleEvent({
368
+ gameId,
369
+ name: 'spoilage',
370
+ triggerTime: dt({ hour: 9 }),
371
+ consequence: { resourceId: resource.id, delta: -10 },
372
+ });
373
+ // Fault injection: simulate a failure deep inside updateResourceValue's
374
+ // own nested transaction (e.g. a disk error), after the resources
375
+ // UPDATE has already run but before it commits.
376
+ const db = getDatabase();
377
+ db.exec(`
378
+ CREATE TRIGGER fail_resource_history_insert_time
379
+ BEFORE INSERT ON resource_history
380
+ BEGIN
381
+ SELECT RAISE(ABORT, 'simulated failure');
382
+ END;
383
+ `);
384
+ const result = advanceTime(gameId, { hours: 2 });
385
+ expect(result?.triggeredEvents).toHaveLength(0);
386
+ expect(result?.consequenceFailures).toHaveLength(1);
387
+ expect(getResource(resource.id)?.value).toBe(100);
388
+ expect(getResourceHistory(resource.id)).toHaveLength(0);
389
+ expect(listScheduledEvents(gameId, false)).toHaveLength(1);
390
+ });
391
+ it('does not reschedule a recurring event when its consequence fails -- the same occurrence stays due', () => {
392
+ scheduleEvent({
393
+ gameId,
394
+ name: 'broken daily toll',
395
+ triggerTime: dt({ hour: 9 }),
396
+ recurring: 'daily',
397
+ consequence: { resourceId: 'does-not-exist', delta: -5 },
398
+ });
399
+ advanceTime(gameId, { hours: 2 });
400
+ const [event] = listScheduledEvents(gameId, false);
401
+ expect(event.triggerTime).toEqual(dt({ hour: 9 }));
402
+ });
403
+ });
404
+ });
@@ -0,0 +1 @@
1
+ export {};