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,176 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase } from "../db/connection.js";
3
+ export function addTag(params) {
4
+ const db = getDatabase();
5
+ const id = uuidv4();
6
+ const now = new Date().toISOString();
7
+ // Check if tag already exists (unique constraint)
8
+ const existing = db.prepare(`
9
+ SELECT id FROM tags
10
+ WHERE game_id = ? AND entity_id = ? AND entity_type = ? AND tag = ?
11
+ `).get(params.gameId, params.entityId, params.entityType, params.tag);
12
+ if (existing) {
13
+ // Update existing tag
14
+ const existingId = existing.id;
15
+ db.prepare(`
16
+ UPDATE tags SET color = ?, notes = ? WHERE id = ?
17
+ `).run(params.color || null, params.notes || "", existingId);
18
+ const updated = getTag(existingId);
19
+ if (!updated) {
20
+ throw new Error(`Failed to retrieve updated tag '${existingId}'`);
21
+ }
22
+ return updated;
23
+ }
24
+ db.prepare(`
25
+ INSERT INTO tags (id, game_id, entity_id, entity_type, tag, color, notes, created_at)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
27
+ `).run(id, params.gameId, params.entityId, params.entityType, params.tag, params.color || null, params.notes || "", now);
28
+ return {
29
+ id,
30
+ gameId: params.gameId,
31
+ entityId: params.entityId,
32
+ entityType: params.entityType,
33
+ tag: params.tag,
34
+ color: params.color || null,
35
+ notes: params.notes || "",
36
+ createdAt: now,
37
+ };
38
+ }
39
+ export function getTag(id) {
40
+ const db = getDatabase();
41
+ const row = db.prepare(`SELECT * FROM tags WHERE id = ?`).get(id);
42
+ if (!row)
43
+ return null;
44
+ return {
45
+ id: row.id,
46
+ gameId: row.game_id,
47
+ entityId: row.entity_id,
48
+ entityType: row.entity_type,
49
+ tag: row.tag,
50
+ color: row.color,
51
+ notes: row.notes,
52
+ createdAt: row.created_at,
53
+ };
54
+ }
55
+ export function removeTag(params) {
56
+ const db = getDatabase();
57
+ const result = db.prepare(`
58
+ DELETE FROM tags
59
+ WHERE game_id = ? AND entity_id = ? AND entity_type = ? AND tag = ?
60
+ `).run(params.gameId, params.entityId, params.entityType, params.tag);
61
+ return result.changes > 0;
62
+ }
63
+ export function listTags(gameId) {
64
+ const db = getDatabase();
65
+ const rows = db.prepare(`
66
+ SELECT tag, color, COUNT(*) as count
67
+ FROM tags
68
+ WHERE game_id = ?
69
+ GROUP BY tag
70
+ ORDER BY count DESC, tag ASC
71
+ `).all(gameId);
72
+ return rows.map(row => ({
73
+ tag: row.tag,
74
+ count: row.count,
75
+ color: row.color,
76
+ }));
77
+ }
78
+ export function getEntityTags(entityId, entityType) {
79
+ const db = getDatabase();
80
+ const rows = db.prepare(`
81
+ SELECT * FROM tags WHERE entity_id = ? AND entity_type = ?
82
+ ORDER BY tag
83
+ `).all(entityId, entityType);
84
+ return rows.map(row => ({
85
+ id: row.id,
86
+ gameId: row.game_id,
87
+ entityId: row.entity_id,
88
+ entityType: row.entity_type,
89
+ tag: row.tag,
90
+ color: row.color,
91
+ notes: row.notes,
92
+ createdAt: row.created_at,
93
+ }));
94
+ }
95
+ export function findByTag(gameId, tag, entityType) {
96
+ const db = getDatabase();
97
+ let query = `SELECT * FROM tags WHERE game_id = ? AND tag = ?`;
98
+ const params = [gameId, tag];
99
+ if (entityType) {
100
+ query += ` AND entity_type = ?`;
101
+ params.push(entityType);
102
+ }
103
+ query += ` ORDER BY entity_type, created_at`;
104
+ const rows = db.prepare(query).all(...params);
105
+ return rows.map(row => ({
106
+ entityId: row.entity_id,
107
+ entityType: row.entity_type,
108
+ tag: {
109
+ id: row.id,
110
+ gameId: row.game_id,
111
+ entityId: row.entity_id,
112
+ entityType: row.entity_type,
113
+ tag: row.tag,
114
+ color: row.color,
115
+ notes: row.notes,
116
+ createdAt: row.created_at,
117
+ },
118
+ }));
119
+ }
120
+ export function renameTag(gameId, oldTag, newTag) {
121
+ const db = getDatabase();
122
+ const result = db.prepare(`
123
+ UPDATE tags SET tag = ? WHERE game_id = ? AND tag = ?
124
+ `).run(newTag, gameId, oldTag);
125
+ return result.changes;
126
+ }
127
+ export function deleteTagById(id) {
128
+ const db = getDatabase();
129
+ const result = db.prepare(`DELETE FROM tags WHERE id = ?`).run(id);
130
+ return result.changes > 0;
131
+ }
132
+ /**
133
+ * Modify tags on an entity - add and/or remove in a single call.
134
+ * This consolidated function reduces the number of tool calls needed.
135
+ */
136
+ export function modifyTags(params) {
137
+ const added = [];
138
+ const removed = [];
139
+ // Remove tags first
140
+ if (params.remove) {
141
+ for (const tag of params.remove) {
142
+ const success = removeTag({
143
+ gameId: params.gameId,
144
+ entityId: params.entityId,
145
+ entityType: params.entityType,
146
+ tag,
147
+ });
148
+ if (success) {
149
+ removed.push(tag);
150
+ }
151
+ }
152
+ }
153
+ // Add new tags
154
+ if (params.add) {
155
+ for (const tagData of params.add) {
156
+ addTag({
157
+ gameId: params.gameId,
158
+ entityId: params.entityId,
159
+ entityType: params.entityType,
160
+ tag: tagData.tag,
161
+ color: tagData.color,
162
+ notes: tagData.notes,
163
+ });
164
+ added.push(tagData.tag);
165
+ }
166
+ }
167
+ // Get final tag list
168
+ const finalTags = getEntityTags(params.entityId, params.entityType);
169
+ return {
170
+ entityId: params.entityId,
171
+ entityType: params.entityType,
172
+ tags: finalTags.map(t => t.tag),
173
+ added,
174
+ removed,
175
+ };
176
+ }
@@ -0,0 +1,33 @@
1
+ import type { GameTime, GameDateTime, CalendarConfig, ScheduledEvent, ExpiryConsequence } from "../types/index.js";
2
+ export declare function setCalendar(gameId: string, config: Partial<CalendarConfig>, currentTime?: GameDateTime): GameTime;
3
+ export declare function getTime(gameId: string): GameTime | null;
4
+ export declare function setTime(gameId: string, time: GameDateTime): GameTime | null;
5
+ export interface ConsequenceFailure {
6
+ eventId: string;
7
+ eventName: string;
8
+ consequence: ExpiryConsequence;
9
+ error: string;
10
+ }
11
+ export interface AdvanceResult {
12
+ previousTime: GameDateTime;
13
+ newTime: GameDateTime;
14
+ triggeredEvents: ScheduledEvent[];
15
+ consequenceFailures: ConsequenceFailure[];
16
+ }
17
+ export declare function advanceTime(gameId: string, duration: {
18
+ days?: number;
19
+ hours?: number;
20
+ minutes?: number;
21
+ }): AdvanceResult | null;
22
+ export declare function scheduleEvent(params: {
23
+ gameId: string;
24
+ name: string;
25
+ description?: string;
26
+ triggerTime: GameDateTime;
27
+ recurring?: string;
28
+ metadata?: Record<string, unknown>;
29
+ consequence?: ExpiryConsequence;
30
+ }): ScheduledEvent;
31
+ export declare function listScheduledEvents(gameId: string, includeTriggered?: boolean): ScheduledEvent[];
32
+ export declare function cancelEvent(eventId: string): boolean;
33
+ export declare function formatDateTime(dt: GameDateTime, config: CalendarConfig): string;
@@ -0,0 +1,276 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase, withTransaction } from "../db/connection.js";
3
+ import { safeJsonParse } from "../utils/json.js";
4
+ import { updateResourceValue } from "./resource.js";
5
+ import { createLogger } from "../utils/logger.js";
6
+ const log = createLogger("time");
7
+ function parseConsequence(raw) {
8
+ if (typeof raw !== "string" || raw.length === 0)
9
+ return null;
10
+ return safeJsonParse(raw, null);
11
+ }
12
+ // Default calendar (fantasy-style)
13
+ const DEFAULT_CALENDAR = {
14
+ monthNames: [
15
+ "Deepwinter", "Thawing", "Seedtime", "Blossoming", "Highsun", "Summertide",
16
+ "Harvest", "Leaffall", "Dimming", "Frostfall", "Darknight", "Yearsend"
17
+ ],
18
+ daysPerMonth: [30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
19
+ hoursPerDay: 24,
20
+ minutesPerHour: 60,
21
+ startYear: 1,
22
+ eraName: "Age of Wonder",
23
+ };
24
+ function dateTimeToMinutes(dt, config) {
25
+ let totalMinutes = dt.minute;
26
+ totalMinutes += dt.hour * config.minutesPerHour;
27
+ totalMinutes += dt.day * config.hoursPerDay * config.minutesPerHour;
28
+ // Add days from previous months
29
+ for (let m = 0; m < dt.month; m++) {
30
+ totalMinutes += config.daysPerMonth[m] * config.hoursPerDay * config.minutesPerHour;
31
+ }
32
+ // Add days from previous years
33
+ const daysPerYear = config.daysPerMonth.reduce((a, b) => a + b, 0);
34
+ totalMinutes += (dt.year - config.startYear) * daysPerYear * config.hoursPerDay * config.minutesPerHour;
35
+ return totalMinutes;
36
+ }
37
+ function minutesToDateTime(totalMinutes, config) {
38
+ const minutesPerHour = config.minutesPerHour;
39
+ const minutesPerDay = config.hoursPerDay * minutesPerHour;
40
+ const daysPerYear = config.daysPerMonth.reduce((a, b) => a + b, 0);
41
+ const minutesPerYear = daysPerYear * minutesPerDay;
42
+ let remaining = totalMinutes;
43
+ const year = config.startYear + Math.floor(remaining / minutesPerYear);
44
+ remaining = remaining % minutesPerYear;
45
+ let month = 0;
46
+ for (let m = 0; m < config.daysPerMonth.length; m++) {
47
+ const monthMinutes = config.daysPerMonth[m] * minutesPerDay;
48
+ if (remaining < monthMinutes) {
49
+ month = m;
50
+ break;
51
+ }
52
+ remaining -= monthMinutes;
53
+ }
54
+ const day = Math.floor(remaining / minutesPerDay);
55
+ remaining = remaining % minutesPerDay;
56
+ const hour = Math.floor(remaining / minutesPerHour);
57
+ const minute = remaining % minutesPerHour;
58
+ return { year, month, day, hour, minute };
59
+ }
60
+ function compareDateTime(a, b, config) {
61
+ return dateTimeToMinutes(a, config) - dateTimeToMinutes(b, config);
62
+ }
63
+ export function setCalendar(gameId, config, currentTime) {
64
+ const db = getDatabase();
65
+ const calendarConfig = {
66
+ ...DEFAULT_CALENDAR,
67
+ ...config,
68
+ };
69
+ const time = currentTime || {
70
+ year: calendarConfig.startYear,
71
+ month: 0,
72
+ day: 0,
73
+ hour: 8,
74
+ minute: 0,
75
+ };
76
+ // Upsert
77
+ const existing = db.prepare(`SELECT game_id FROM game_time WHERE game_id = ?`).get(gameId);
78
+ if (existing) {
79
+ db.prepare(`UPDATE game_time SET current_time = ?, calendar_config = ? WHERE game_id = ?`)
80
+ .run(JSON.stringify(time), JSON.stringify(calendarConfig), gameId);
81
+ }
82
+ else {
83
+ db.prepare(`INSERT INTO game_time (game_id, current_time, calendar_config) VALUES (?, ?, ?)`)
84
+ .run(gameId, JSON.stringify(time), JSON.stringify(calendarConfig));
85
+ }
86
+ return { gameId, currentTime: time, calendarConfig };
87
+ }
88
+ export function getTime(gameId) {
89
+ const db = getDatabase();
90
+ const row = db.prepare(`SELECT * FROM game_time WHERE game_id = ?`).get(gameId);
91
+ if (!row)
92
+ return null;
93
+ return {
94
+ gameId: row.game_id,
95
+ currentTime: safeJsonParse(row.current_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 }),
96
+ calendarConfig: safeJsonParse(row.calendar_config, DEFAULT_CALENDAR),
97
+ };
98
+ }
99
+ export function setTime(gameId, time) {
100
+ const db = getDatabase();
101
+ const gameTime = getTime(gameId);
102
+ if (!gameTime)
103
+ return null;
104
+ db.prepare(`UPDATE game_time SET current_time = ? WHERE game_id = ?`)
105
+ .run(JSON.stringify(time), gameId);
106
+ return { ...gameTime, currentTime: time };
107
+ }
108
+ export function advanceTime(gameId, duration) {
109
+ const db = getDatabase();
110
+ const gameTime = getTime(gameId);
111
+ if (!gameTime)
112
+ return null;
113
+ const { currentTime, calendarConfig } = gameTime;
114
+ const previousTime = { ...currentTime };
115
+ // Convert to minutes, add, convert back
116
+ let totalMinutes = dateTimeToMinutes(currentTime, calendarConfig);
117
+ totalMinutes += (duration.minutes || 0);
118
+ totalMinutes += (duration.hours || 0) * calendarConfig.minutesPerHour;
119
+ totalMinutes += (duration.days || 0) * calendarConfig.hoursPerDay * calendarConfig.minutesPerHour;
120
+ const newTime = minutesToDateTime(totalMinutes, calendarConfig);
121
+ // Update time. This happens unconditionally and outside any per-event
122
+ // transaction below -- the clock is global state, not tied to any one
123
+ // event's consequence, so a broken consequence on one event must never
124
+ // block time from moving for everyone else.
125
+ db.prepare(`UPDATE game_time SET current_time = ? WHERE game_id = ?`)
126
+ .run(JSON.stringify(newTime), gameId);
127
+ // Check for triggered events
128
+ const events = db.prepare(`
129
+ SELECT * FROM scheduled_events
130
+ WHERE game_id = ? AND triggered = 0
131
+ `).all(gameId);
132
+ const triggeredEvents = [];
133
+ const consequenceFailures = [];
134
+ for (const row of events) {
135
+ const triggerTime = safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 });
136
+ // Check if event should trigger (trigger time is between previous and new time)
137
+ if (compareDateTime(triggerTime, previousTime, calendarConfig) >= 0 &&
138
+ compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
139
+ const eventId = row.id;
140
+ const eventName = row.name;
141
+ const recurring = row.recurring;
142
+ const consequence = parseConsequence(row.consequence);
143
+ // Recurring events reschedule instead of being marked triggered=1.
144
+ const newTrigger = recurring ? rescheduleEvent(triggerTime, recurring, calendarConfig) : null;
145
+ // The row mutation (triggered=1, or reschedule to the next
146
+ // occurrence) and the consequence application must land together or
147
+ // not at all -- otherwise a failed consequence could leave the event
148
+ // permanently marked "handled" with its effect never applied, and
149
+ // exactly-once would be broken forever with no way to retry.
150
+ try {
151
+ withTransaction(() => {
152
+ if (newTrigger) {
153
+ db.prepare(`UPDATE scheduled_events SET trigger_time = ? WHERE id = ?`)
154
+ .run(JSON.stringify(newTrigger), eventId);
155
+ }
156
+ else {
157
+ db.prepare(`UPDATE scheduled_events SET triggered = 1 WHERE id = ?`)
158
+ .run(eventId);
159
+ }
160
+ if (consequence) {
161
+ const applied = updateResourceValue({
162
+ resourceId: consequence.resourceId,
163
+ mode: "delta",
164
+ value: consequence.delta,
165
+ reason: `Expiry consequence: ${eventName}`,
166
+ });
167
+ if (!applied) {
168
+ throw new Error(`Resource '${consequence.resourceId}' not found for consequence of scheduled event '${eventName}' (${eventId})`);
169
+ }
170
+ }
171
+ });
172
+ }
173
+ catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ log.error("Failed to apply expiry consequence for scheduled event; leaving it pending", {
176
+ gameId,
177
+ eventId,
178
+ eventName,
179
+ consequence,
180
+ error: message,
181
+ });
182
+ if (consequence) {
183
+ consequenceFailures.push({ eventId, eventName, consequence, error: message });
184
+ }
185
+ // Transaction rolled back: the row is untouched, so the event stays
186
+ // pending (not triggered, not rescheduled) and is excluded from
187
+ // triggeredEvents below -- no expired-but-unapplied state.
188
+ continue;
189
+ }
190
+ triggeredEvents.push({
191
+ id: eventId,
192
+ gameId: row.game_id,
193
+ name: eventName,
194
+ description: row.description || "",
195
+ triggerTime,
196
+ recurring,
197
+ triggered: !newTrigger,
198
+ metadata: safeJsonParse(row.metadata || "{}", {}),
199
+ consequence,
200
+ });
201
+ }
202
+ }
203
+ return { previousTime, newTime, triggeredEvents, consequenceFailures };
204
+ }
205
+ function rescheduleEvent(current, recurring, config) {
206
+ const minutesPerDay = config.hoursPerDay * config.minutesPerHour;
207
+ const daysPerYear = config.daysPerMonth.reduce((a, b) => a + b, 0);
208
+ let minutes = dateTimeToMinutes(current, config);
209
+ switch (recurring) {
210
+ case "daily":
211
+ minutes += minutesPerDay;
212
+ break;
213
+ case "weekly":
214
+ minutes += minutesPerDay * 7;
215
+ break;
216
+ case "monthly":
217
+ minutes += config.daysPerMonth[current.month] * minutesPerDay;
218
+ break;
219
+ case "yearly":
220
+ minutes += daysPerYear * minutesPerDay;
221
+ break;
222
+ }
223
+ return minutesToDateTime(minutes, config);
224
+ }
225
+ export function scheduleEvent(params) {
226
+ const db = getDatabase();
227
+ const id = uuidv4();
228
+ const consequence = params.consequence ?? null;
229
+ db.prepare(`
230
+ INSERT INTO scheduled_events (id, game_id, name, description, trigger_time, recurring, metadata, consequence)
231
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
232
+ `).run(id, params.gameId, params.name, params.description || "", JSON.stringify(params.triggerTime), params.recurring || null, JSON.stringify(params.metadata || {}), consequence ? JSON.stringify(consequence) : null);
233
+ return {
234
+ id,
235
+ gameId: params.gameId,
236
+ name: params.name,
237
+ description: params.description || "",
238
+ triggerTime: params.triggerTime,
239
+ recurring: params.recurring || null,
240
+ triggered: false,
241
+ metadata: params.metadata || {},
242
+ consequence,
243
+ };
244
+ }
245
+ export function listScheduledEvents(gameId, includeTriggered = false) {
246
+ const db = getDatabase();
247
+ let query = `SELECT * FROM scheduled_events WHERE game_id = ?`;
248
+ if (!includeTriggered) {
249
+ query += ` AND triggered = 0`;
250
+ }
251
+ query += ` ORDER BY trigger_time`;
252
+ const rows = db.prepare(query).all(gameId);
253
+ return rows.map(row => ({
254
+ id: row.id,
255
+ gameId: row.game_id,
256
+ name: row.name,
257
+ description: row.description || "",
258
+ triggerTime: safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 }),
259
+ recurring: row.recurring,
260
+ triggered: row.triggered === 1,
261
+ metadata: safeJsonParse(row.metadata || "{}", {}),
262
+ consequence: parseConsequence(row.consequence),
263
+ }));
264
+ }
265
+ export function cancelEvent(eventId) {
266
+ const db = getDatabase();
267
+ const result = db.prepare(`DELETE FROM scheduled_events WHERE id = ?`).run(eventId);
268
+ return result.changes > 0;
269
+ }
270
+ export function formatDateTime(dt, config) {
271
+ const monthName = config.monthNames[dt.month] || `Month ${dt.month + 1}`;
272
+ const hour = dt.hour.toString().padStart(2, "0");
273
+ const minute = dt.minute.toString().padStart(2, "0");
274
+ const era = config.eraName ? ` ${config.eraName}` : "";
275
+ return `${dt.day + 1} ${monthName}, Year ${dt.year}${era} - ${hour}:${minute}`;
276
+ }
@@ -0,0 +1,41 @@
1
+ import type { Timer, ExpiryConsequence } from "../types/index.js";
2
+ export declare function createTimer(params: {
3
+ gameId: string;
4
+ name: string;
5
+ description?: string;
6
+ timerType: "countdown" | "stopwatch" | "clock";
7
+ currentValue?: number;
8
+ maxValue?: number;
9
+ direction?: "up" | "down";
10
+ triggerAt?: number;
11
+ unit?: string;
12
+ visibleToPlayers?: boolean;
13
+ consequence?: ExpiryConsequence;
14
+ }): Timer;
15
+ export declare function getTimer(id: string): Timer | null;
16
+ export declare function updateTimer(id: string, updates: {
17
+ name?: string;
18
+ description?: string;
19
+ maxValue?: number | null;
20
+ triggerAt?: number | null;
21
+ unit?: string;
22
+ visibleToPlayers?: boolean;
23
+ }): Timer | null;
24
+ export declare function deleteTimer(id: string): boolean;
25
+ export declare function listTimers(gameId: string, includeTriggered?: boolean): Timer[];
26
+ export interface TickResult {
27
+ timer: Timer;
28
+ previousValue: number;
29
+ justTriggered: boolean;
30
+ consequenceApplied: boolean;
31
+ }
32
+ export declare function tickTimer(id: string, amount?: number): TickResult | null;
33
+ export declare function resetTimer(id: string): Timer | null;
34
+ /**
35
+ * Modify a timer - tick or reset in a single call.
36
+ * Use mode: "tick" to advance by amount, mode: "reset" to reset to initial state.
37
+ */
38
+ export declare function modifyTimerState(id: string, params: {
39
+ mode: "tick" | "reset";
40
+ amount?: number;
41
+ }): TickResult | null;