run-dmcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +79 -0
- package/dist/__tests__/engineVocabulary.test.d.ts +1 -0
- package/dist/__tests__/engineVocabulary.test.js +147 -0
- package/dist/db/__tests__/connection.test.d.ts +1 -0
- package/dist/db/__tests__/connection.test.js +72 -0
- package/dist/db/__tests__/testDb.d.ts +33 -0
- package/dist/db/__tests__/testDb.js +41 -0
- package/dist/db/connection.d.ts +22 -0
- package/dist/db/connection.js +107 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +725 -0
- package/dist/events/emitter.d.ts +22 -0
- package/dist/events/emitter.js +71 -0
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +649 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +92 -0
- package/dist/register/abilities.d.ts +2 -0
- package/dist/register/abilities.js +165 -0
- package/dist/register/audio.d.ts +2 -0
- package/dist/register/audio.js +326 -0
- package/dist/register/batch.d.ts +2 -0
- package/dist/register/batch.js +343 -0
- package/dist/register/character.d.ts +2 -0
- package/dist/register/character.js +324 -0
- package/dist/register/combat.d.ts +2 -0
- package/dist/register/combat.js +207 -0
- package/dist/register/core.d.ts +2 -0
- package/dist/register/core.js +1040 -0
- package/dist/register/display.d.ts +2 -0
- package/dist/register/display.js +263 -0
- package/dist/register/factions.d.ts +2 -0
- package/dist/register/factions.js +186 -0
- package/dist/register/images.d.ts +2 -0
- package/dist/register/images.js +400 -0
- package/dist/register/inventory.d.ts +2 -0
- package/dist/register/inventory.js +115 -0
- package/dist/register/mcp-prompts.d.ts +2 -0
- package/dist/register/mcp-prompts.js +684 -0
- package/dist/register/mcp-resources.d.ts +2 -0
- package/dist/register/mcp-resources.js +335 -0
- package/dist/register/narrative.d.ts +2 -0
- package/dist/register/narrative.js +242 -0
- package/dist/register/notes.d.ts +2 -0
- package/dist/register/notes.js +170 -0
- package/dist/register/pause.d.ts +2 -0
- package/dist/register/pause.js +580 -0
- package/dist/register/quests.d.ts +2 -0
- package/dist/register/quests.js +118 -0
- package/dist/register/relationships.d.ts +2 -0
- package/dist/register/relationships.js +147 -0
- package/dist/register/resources.d.ts +2 -0
- package/dist/register/resources.js +277 -0
- package/dist/register/secrets.d.ts +2 -0
- package/dist/register/secrets.js +192 -0
- package/dist/register/status.d.ts +2 -0
- package/dist/register/status.js +130 -0
- package/dist/register/tables.d.ts +2 -0
- package/dist/register/tables.js +146 -0
- package/dist/register/tags.d.ts +2 -0
- package/dist/register/tags.js +114 -0
- package/dist/register/time.d.ts +2 -0
- package/dist/register/time.js +281 -0
- package/dist/register/world.d.ts +2 -0
- package/dist/register/world.js +127 -0
- package/dist/schemas/index.d.ts +921 -0
- package/dist/schemas/index.js +121 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +13 -0
- package/dist/tools/__tests__/audio.test.d.ts +1 -0
- package/dist/tools/__tests__/audio.test.js +59 -0
- package/dist/tools/__tests__/conserved.test.d.ts +1 -0
- package/dist/tools/__tests__/conserved.test.js +488 -0
- package/dist/tools/__tests__/constraint.test.d.ts +1 -0
- package/dist/tools/__tests__/constraint.test.js +212 -0
- package/dist/tools/__tests__/expiry-consequences.test.d.ts +1 -0
- package/dist/tools/__tests__/expiry-consequences.test.js +110 -0
- package/dist/tools/__tests__/images.test.d.ts +1 -0
- package/dist/tools/__tests__/images.test.js +59 -0
- package/dist/tools/__tests__/relationship.test.d.ts +1 -0
- package/dist/tools/__tests__/relationship.test.js +132 -0
- package/dist/tools/__tests__/resource-constraints.test.d.ts +1 -0
- package/dist/tools/__tests__/resource-constraints.test.js +131 -0
- package/dist/tools/__tests__/resource.test.d.ts +1 -0
- package/dist/tools/__tests__/resource.test.js +190 -0
- package/dist/tools/__tests__/time.test.d.ts +1 -0
- package/dist/tools/__tests__/time.test.js +404 -0
- package/dist/tools/__tests__/timers.test.d.ts +1 -0
- package/dist/tools/__tests__/timers.test.js +426 -0
- package/dist/tools/__tests__/world.test.d.ts +1 -0
- package/dist/tools/__tests__/world.test.js +70 -0
- package/dist/tools/ability.d.ts +48 -0
- package/dist/tools/ability.js +238 -0
- package/dist/tools/audio.d.ts +24 -0
- package/dist/tools/audio.js +365 -0
- package/dist/tools/character.d.ts +70 -0
- package/dist/tools/character.js +309 -0
- package/dist/tools/combat.d.ts +13 -0
- package/dist/tools/combat.js +195 -0
- package/dist/tools/constraint.d.ts +132 -0
- package/dist/tools/constraint.js +269 -0
- package/dist/tools/dice.d.ts +23 -0
- package/dist/tools/dice.js +111 -0
- package/dist/tools/display.d.ts +120 -0
- package/dist/tools/display.js +528 -0
- package/dist/tools/faction.d.ts +61 -0
- package/dist/tools/faction.js +269 -0
- package/dist/tools/game.d.ts +96 -0
- package/dist/tools/game.js +526 -0
- package/dist/tools/image-prompt.d.ts +49 -0
- package/dist/tools/image-prompt.js +479 -0
- package/dist/tools/images.d.ts +47 -0
- package/dist/tools/images.js +449 -0
- package/dist/tools/inventory.d.ts +20 -0
- package/dist/tools/inventory.js +145 -0
- package/dist/tools/narrative.d.ts +58 -0
- package/dist/tools/narrative.js +237 -0
- package/dist/tools/notes.d.ts +41 -0
- package/dist/tools/notes.js +220 -0
- package/dist/tools/pause.d.ts +110 -0
- package/dist/tools/pause.js +1254 -0
- package/dist/tools/quest.d.ts +34 -0
- package/dist/tools/quest.js +164 -0
- package/dist/tools/relationship.d.ts +74 -0
- package/dist/tools/relationship.js +324 -0
- package/dist/tools/resource.d.ts +93 -0
- package/dist/tools/resource.js +374 -0
- package/dist/tools/rules.d.ts +4 -0
- package/dist/tools/rules.js +30 -0
- package/dist/tools/secrets.d.ts +49 -0
- package/dist/tools/secrets.js +195 -0
- package/dist/tools/status.d.ts +36 -0
- package/dist/tools/status.js +218 -0
- package/dist/tools/tables.d.ts +33 -0
- package/dist/tools/tables.js +209 -0
- package/dist/tools/tags.d.ts +52 -0
- package/dist/tools/tags.js +176 -0
- package/dist/tools/time.d.ts +33 -0
- package/dist/tools/time.js +276 -0
- package/dist/tools/timers.d.ts +41 -0
- package/dist/tools/timers.js +215 -0
- package/dist/tools/world.d.ts +78 -0
- package/dist/tools/world.js +331 -0
- package/dist/types/index.d.ts +969 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/__tests__/json.test.d.ts +1 -0
- package/dist/utils/__tests__/json.test.js +55 -0
- package/dist/utils/__tests__/validation.test.d.ts +1 -0
- package/dist/utils/__tests__/validation.test.js +90 -0
- package/dist/utils/errors.d.ts +44 -0
- package/dist/utils/errors.js +121 -0
- package/dist/utils/json.d.ts +9 -0
- package/dist/utils/json.js +23 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +50 -0
- package/dist/utils/output-schemas.d.ts +594 -0
- package/dist/utils/output-schemas.js +331 -0
- package/dist/utils/tool-annotations.d.ts +147 -0
- package/dist/utils/tool-annotations.js +98 -0
- package/dist/utils/validation.d.ts +34 -0
- package/dist/utils/validation.js +52 -0
- package/dist/utils/verbosity.d.ts +57 -0
- package/dist/utils/verbosity.js +67 -0
- package/dist/utils/webui.d.ts +20 -0
- package/dist/utils/webui.js +35 -0
- package/package.json +75 -0
|
@@ -0,0 +1,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;
|