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,309 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase } from "../db/connection.js";
3
+ import { safeJsonParse } from "../utils/json.js";
4
+ import { gameEvents } from "../events/emitter.js";
5
+ import { validateGameExists } from "./game.js";
6
+ export function createCharacter(params) {
7
+ // Validate game exists to prevent orphaned records
8
+ validateGameExists(params.gameId);
9
+ const db = getDatabase();
10
+ const id = uuidv4();
11
+ const now = new Date().toISOString();
12
+ const status = {
13
+ health: 100,
14
+ maxHealth: 100,
15
+ conditions: [],
16
+ experience: 0,
17
+ level: 1,
18
+ ...params.status,
19
+ };
20
+ const stmt = db.prepare(`
21
+ INSERT INTO characters (id, game_id, name, is_player, attributes, skills, status, location_id, notes, voice, image_gen, created_at)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
23
+ `);
24
+ stmt.run(id, params.gameId, params.name, params.isPlayer ? 1 : 0, JSON.stringify(params.attributes || {}), JSON.stringify(params.skills || {}), JSON.stringify(status), params.locationId || null, params.notes || "", params.voice ? JSON.stringify(params.voice) : null, params.imageGen ? JSON.stringify(params.imageGen) : null, now);
25
+ const character = {
26
+ id,
27
+ gameId: params.gameId,
28
+ name: params.name,
29
+ isPlayer: params.isPlayer,
30
+ attributes: params.attributes || {},
31
+ skills: params.skills || {},
32
+ status,
33
+ locationId: params.locationId || null,
34
+ notes: params.notes || "",
35
+ voice: params.voice || null,
36
+ imageGen: params.imageGen || null,
37
+ createdAt: now,
38
+ };
39
+ // Emit realtime event
40
+ gameEvents.emit({
41
+ type: "character:created",
42
+ gameId: params.gameId,
43
+ entityId: id,
44
+ entityType: "character",
45
+ timestamp: now,
46
+ data: { name: params.name, isPlayer: params.isPlayer },
47
+ });
48
+ return character;
49
+ }
50
+ export function getCharacter(id) {
51
+ const db = getDatabase();
52
+ const stmt = db.prepare(`SELECT * FROM characters WHERE id = ?`);
53
+ const row = stmt.get(id);
54
+ if (!row)
55
+ return null;
56
+ return {
57
+ id: row.id,
58
+ gameId: row.game_id,
59
+ name: row.name,
60
+ isPlayer: row.is_player === 1,
61
+ attributes: safeJsonParse(row.attributes, {}),
62
+ skills: safeJsonParse(row.skills, {}),
63
+ status: safeJsonParse(row.status, { health: 0, maxHealth: 0, conditions: [], experience: 0, level: 1 }),
64
+ locationId: row.location_id,
65
+ notes: row.notes,
66
+ voice: row.voice ? safeJsonParse(row.voice, null) : null,
67
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
68
+ createdAt: row.created_at,
69
+ };
70
+ }
71
+ export function updateCharacter(id, updates) {
72
+ const db = getDatabase();
73
+ const current = getCharacter(id);
74
+ if (!current)
75
+ return null;
76
+ const newName = updates.name ?? current.name;
77
+ const newAttributes = updates.attributes
78
+ ? { ...current.attributes, ...updates.attributes }
79
+ : current.attributes;
80
+ const newSkills = updates.skills
81
+ ? { ...current.skills, ...updates.skills }
82
+ : current.skills;
83
+ const newStatus = updates.status
84
+ ? { ...current.status, ...updates.status }
85
+ : current.status;
86
+ const newLocationId = updates.locationId !== undefined ? updates.locationId : current.locationId;
87
+ const newNotes = updates.notes ?? current.notes;
88
+ const newVoice = updates.voice !== undefined ? updates.voice : current.voice;
89
+ const newImageGen = updates.imageGen !== undefined ? updates.imageGen : current.imageGen;
90
+ const stmt = db.prepare(`
91
+ UPDATE characters
92
+ SET name = ?, attributes = ?, skills = ?, status = ?, location_id = ?, notes = ?, voice = ?, image_gen = ?
93
+ WHERE id = ?
94
+ `);
95
+ stmt.run(newName, JSON.stringify(newAttributes), JSON.stringify(newSkills), JSON.stringify(newStatus), newLocationId, newNotes, newVoice ? JSON.stringify(newVoice) : null, newImageGen ? JSON.stringify(newImageGen) : null, id);
96
+ const updated = {
97
+ ...current,
98
+ name: newName,
99
+ attributes: newAttributes,
100
+ skills: newSkills,
101
+ status: newStatus,
102
+ locationId: newLocationId,
103
+ notes: newNotes,
104
+ voice: newVoice,
105
+ imageGen: newImageGen,
106
+ };
107
+ // Emit realtime event
108
+ gameEvents.emit({
109
+ type: "character:updated",
110
+ gameId: current.gameId,
111
+ entityId: id,
112
+ entityType: "character",
113
+ timestamp: new Date().toISOString(),
114
+ data: { name: newName },
115
+ });
116
+ return updated;
117
+ }
118
+ export function deleteCharacter(id) {
119
+ const db = getDatabase();
120
+ const stmt = db.prepare(`DELETE FROM characters WHERE id = ?`);
121
+ const result = stmt.run(id);
122
+ return result.changes > 0;
123
+ }
124
+ export function listCharacters(gameId, filter) {
125
+ const db = getDatabase();
126
+ let query = `SELECT * FROM characters WHERE game_id = ?`;
127
+ const params = [gameId];
128
+ if (filter?.isPlayer !== undefined) {
129
+ query += ` AND is_player = ?`;
130
+ params.push(filter.isPlayer ? 1 : 0);
131
+ }
132
+ if (filter?.locationId !== undefined) {
133
+ query += ` AND location_id = ?`;
134
+ params.push(filter.locationId);
135
+ }
136
+ const stmt = db.prepare(query);
137
+ const rows = stmt.all(...params);
138
+ return rows.map((row) => ({
139
+ id: row.id,
140
+ gameId: row.game_id,
141
+ name: row.name,
142
+ isPlayer: row.is_player === 1,
143
+ attributes: safeJsonParse(row.attributes, {}),
144
+ skills: safeJsonParse(row.skills, {}),
145
+ status: safeJsonParse(row.status, { health: 0, maxHealth: 0, conditions: [], experience: 0, level: 1 }),
146
+ locationId: row.location_id,
147
+ notes: row.notes,
148
+ voice: row.voice ? safeJsonParse(row.voice, null) : null,
149
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
150
+ createdAt: row.created_at,
151
+ }));
152
+ }
153
+ /**
154
+ * Find a character by name (case-insensitive, fuzzy match).
155
+ * Returns the best match or null if no reasonable match found.
156
+ */
157
+ export function getCharacterByName(gameId, name) {
158
+ const db = getDatabase();
159
+ const searchName = name.toLowerCase().trim();
160
+ // First try exact match (case-insensitive)
161
+ const exactMatch = db.prepare(`SELECT * FROM characters WHERE game_id = ? AND LOWER(name) = ?`).get(gameId, searchName);
162
+ if (exactMatch) {
163
+ return mapRowToCharacter(exactMatch);
164
+ }
165
+ // Try contains match
166
+ const containsMatch = db.prepare(`SELECT * FROM characters WHERE game_id = ? AND LOWER(name) LIKE ?`).get(gameId, `%${searchName}%`);
167
+ if (containsMatch) {
168
+ return mapRowToCharacter(containsMatch);
169
+ }
170
+ // Try matching just the last word (e.g., "Gannik" matches "Master Gannik")
171
+ const words = searchName.split(/\s+/);
172
+ if (words.length > 0) {
173
+ const lastName = words[words.length - 1];
174
+ const lastNameMatch = db.prepare(`SELECT * FROM characters WHERE game_id = ? AND LOWER(name) LIKE ?`).get(gameId, `%${lastName}%`);
175
+ if (lastNameMatch) {
176
+ return mapRowToCharacter(lastNameMatch);
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+ // Helper function to map database row to Character
182
+ function mapRowToCharacter(row) {
183
+ return {
184
+ id: row.id,
185
+ gameId: row.game_id,
186
+ name: row.name,
187
+ isPlayer: row.is_player === 1,
188
+ attributes: safeJsonParse(row.attributes, {}),
189
+ skills: safeJsonParse(row.skills, {}),
190
+ status: safeJsonParse(row.status, { health: 0, maxHealth: 0, conditions: [], experience: 0, level: 1 }),
191
+ locationId: row.location_id,
192
+ notes: row.notes,
193
+ voice: row.voice ? safeJsonParse(row.voice, null) : null,
194
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
195
+ createdAt: row.created_at,
196
+ };
197
+ }
198
+ export function moveCharacter(characterId, locationId) {
199
+ const db = getDatabase();
200
+ const stmt = db.prepare(`UPDATE characters SET location_id = ? WHERE id = ?`);
201
+ const result = stmt.run(locationId, characterId);
202
+ return result.changes > 0;
203
+ }
204
+ /**
205
+ * Modify a character's health - damage or heal in a single call.
206
+ * Use mode: "damage" to reduce health, mode: "heal" to restore health.
207
+ */
208
+ export function modifyHealth(characterId, params) {
209
+ const character = getCharacter(characterId);
210
+ if (!character)
211
+ return null;
212
+ const previousHealth = character.status.health;
213
+ let newHealth;
214
+ if (params.mode === "damage") {
215
+ newHealth = Math.max(0, character.status.health - params.amount);
216
+ }
217
+ else {
218
+ newHealth = Math.min(character.status.maxHealth, character.status.health + params.amount);
219
+ }
220
+ const updated = updateCharacter(characterId, {
221
+ status: { health: newHealth },
222
+ });
223
+ if (!updated)
224
+ return null;
225
+ return { character: updated, previousHealth, newHealth };
226
+ }
227
+ export function addCondition(characterId, condition) {
228
+ const character = getCharacter(characterId);
229
+ if (!character)
230
+ return null;
231
+ if (!character.status.conditions.includes(condition)) {
232
+ return updateCharacter(characterId, {
233
+ status: {
234
+ conditions: [...character.status.conditions, condition],
235
+ },
236
+ });
237
+ }
238
+ return character;
239
+ }
240
+ export function removeCondition(characterId, condition) {
241
+ const character = getCharacter(characterId);
242
+ if (!character)
243
+ return null;
244
+ return updateCharacter(characterId, {
245
+ status: {
246
+ conditions: character.status.conditions.filter((c) => c !== condition),
247
+ },
248
+ });
249
+ }
250
+ /**
251
+ * Modify conditions on a character - add or remove in a single call.
252
+ * This consolidated function reduces the number of tool calls needed.
253
+ */
254
+ export function modifyConditions(characterId, params) {
255
+ const character = getCharacter(characterId);
256
+ if (!character)
257
+ return null;
258
+ let conditions = [...character.status.conditions];
259
+ const added = [];
260
+ const removed = [];
261
+ // Remove conditions first (so we can re-add if needed)
262
+ if (params.remove) {
263
+ for (const condition of params.remove) {
264
+ if (conditions.includes(condition)) {
265
+ conditions = conditions.filter((c) => c !== condition);
266
+ removed.push(condition);
267
+ }
268
+ }
269
+ }
270
+ // Add new conditions
271
+ if (params.add) {
272
+ for (const condition of params.add) {
273
+ if (!conditions.includes(condition)) {
274
+ conditions.push(condition);
275
+ added.push(condition);
276
+ }
277
+ }
278
+ }
279
+ const updated = updateCharacter(characterId, {
280
+ status: { conditions },
281
+ });
282
+ if (!updated)
283
+ return null;
284
+ return { character: updated, added, removed };
285
+ }
286
+ export function renderCharacterSheet(characterId) {
287
+ const db = getDatabase();
288
+ const character = getCharacter(characterId);
289
+ if (!character)
290
+ return null;
291
+ // Get location name
292
+ let locationName = null;
293
+ if (character.locationId) {
294
+ const loc = db.prepare(`SELECT name FROM locations WHERE id = ?`).get(character.locationId);
295
+ locationName = loc?.name || null;
296
+ }
297
+ // Get inventory
298
+ const items = db.prepare(`SELECT name, properties FROM items WHERE owner_id = ? AND owner_type = 'character'`)
299
+ .all(characterId);
300
+ const inventory = items.map(item => {
301
+ const props = safeJsonParse(item.properties, {});
302
+ return { name: item.name, type: props.type };
303
+ });
304
+ return {
305
+ character,
306
+ locationName,
307
+ inventory,
308
+ };
309
+ }
@@ -0,0 +1,13 @@
1
+ import type { Combat } from "../types/index.js";
2
+ export declare function startCombat(params: {
3
+ gameId: string;
4
+ locationId: string;
5
+ participantIds: string[];
6
+ }): Combat;
7
+ export declare function getCombat(id: string): Combat | null;
8
+ export declare function getActiveCombat(gameId: string): Combat | null;
9
+ export declare function nextTurn(combatId: string): Combat | null;
10
+ export declare function addCombatLog(combatId: string, entry: string): Combat | null;
11
+ export declare function removeParticipant(combatId: string, characterId: string): Combat | null;
12
+ export declare function endCombat(combatId: string): Combat | null;
13
+ export declare function getCurrentCombatant(combatId: string): string | null;
@@ -0,0 +1,195 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase } from "../db/connection.js";
3
+ import { safeJsonParse } from "../utils/json.js";
4
+ import { gameEvents } from "../events/emitter.js";
5
+ import { validateGameExists } from "./game.js";
6
+ import { getCharacter } from "./character.js";
7
+ import { roll } from "./dice.js";
8
+ import { getRules } from "./rules.js";
9
+ export function startCombat(params) {
10
+ // Validate game exists to prevent orphaned records
11
+ validateGameExists(params.gameId);
12
+ const db = getDatabase();
13
+ const id = uuidv4();
14
+ const rules = getRules(params.gameId);
15
+ // Validate all participants exist before processing (prevents race conditions)
16
+ const characters = params.participantIds.map((charId) => {
17
+ const character = getCharacter(charId);
18
+ if (!character) {
19
+ throw new Error(`Character '${charId}' not found. Cannot start combat with missing participants.`);
20
+ }
21
+ return { charId, character };
22
+ });
23
+ // Roll initiative for each validated participant
24
+ const participants = characters.map(({ charId, character }) => {
25
+ let initiative = 0;
26
+ if (rules) {
27
+ // Simple initiative based on a d20 roll
28
+ // The DM can configure this in combat rules
29
+ const initRoll = roll("1d20");
30
+ // Add dexterity modifier if available
31
+ const dexMod = character.attributes.dexterity
32
+ ? Math.floor((character.attributes.dexterity - 10) / 2)
33
+ : 0;
34
+ initiative = initRoll.total + dexMod;
35
+ }
36
+ else {
37
+ initiative = roll("1d20").total;
38
+ }
39
+ return {
40
+ characterId: charId,
41
+ initiative,
42
+ isActive: true,
43
+ };
44
+ });
45
+ // Sort by initiative (highest first)
46
+ participants.sort((a, b) => b.initiative - a.initiative);
47
+ const stmt = db.prepare(`
48
+ INSERT INTO combats (id, game_id, location_id, participants, current_turn, round, status, log)
49
+ VALUES (?, ?, ?, ?, 0, 1, 'active', '[]')
50
+ `);
51
+ stmt.run(id, params.gameId, params.locationId, JSON.stringify(participants));
52
+ const combat = {
53
+ id,
54
+ gameId: params.gameId,
55
+ locationId: params.locationId,
56
+ participants,
57
+ currentTurn: 0,
58
+ round: 1,
59
+ status: "active",
60
+ log: [],
61
+ };
62
+ // Emit realtime event
63
+ gameEvents.emit({
64
+ type: "combat:started",
65
+ gameId: params.gameId,
66
+ entityId: id,
67
+ entityType: "combat",
68
+ timestamp: new Date().toISOString(),
69
+ data: { participantCount: participants.length },
70
+ });
71
+ return combat;
72
+ }
73
+ export function getCombat(id) {
74
+ const db = getDatabase();
75
+ const stmt = db.prepare(`SELECT * FROM combats WHERE id = ?`);
76
+ const row = stmt.get(id);
77
+ if (!row)
78
+ return null;
79
+ return {
80
+ id: row.id,
81
+ gameId: row.game_id,
82
+ locationId: row.location_id,
83
+ participants: safeJsonParse(row.participants, []),
84
+ currentTurn: row.current_turn,
85
+ round: row.round,
86
+ status: row.status,
87
+ log: safeJsonParse(row.log, []),
88
+ };
89
+ }
90
+ export function getActiveCombat(gameId) {
91
+ const db = getDatabase();
92
+ const stmt = db.prepare(`SELECT * FROM combats WHERE game_id = ? AND status = 'active' LIMIT 1`);
93
+ const row = stmt.get(gameId);
94
+ if (!row)
95
+ return null;
96
+ return {
97
+ id: row.id,
98
+ gameId: row.game_id,
99
+ locationId: row.location_id,
100
+ participants: safeJsonParse(row.participants, []),
101
+ currentTurn: row.current_turn,
102
+ round: row.round,
103
+ status: row.status,
104
+ log: safeJsonParse(row.log, []),
105
+ };
106
+ }
107
+ export function nextTurn(combatId) {
108
+ const combat = getCombat(combatId);
109
+ if (!combat || combat.status !== "active")
110
+ return null;
111
+ const db = getDatabase();
112
+ // Find next active participant
113
+ let nextTurn = combat.currentTurn;
114
+ let round = combat.round;
115
+ let attempts = 0;
116
+ const maxAttempts = combat.participants.length;
117
+ do {
118
+ nextTurn = (nextTurn + 1) % combat.participants.length;
119
+ if (nextTurn === 0) {
120
+ round++;
121
+ }
122
+ attempts++;
123
+ } while (!combat.participants[nextTurn].isActive &&
124
+ attempts < maxAttempts);
125
+ // If no active participants, end combat
126
+ if (attempts >= maxAttempts) {
127
+ return endCombat(combatId);
128
+ }
129
+ const stmt = db.prepare(`
130
+ UPDATE combats SET current_turn = ?, round = ? WHERE id = ?
131
+ `);
132
+ stmt.run(nextTurn, round, combatId);
133
+ return {
134
+ ...combat,
135
+ currentTurn: nextTurn,
136
+ round,
137
+ };
138
+ }
139
+ export function addCombatLog(combatId, entry) {
140
+ const combat = getCombat(combatId);
141
+ if (!combat)
142
+ return null;
143
+ const db = getDatabase();
144
+ const log = [...combat.log, entry];
145
+ const stmt = db.prepare(`UPDATE combats SET log = ? WHERE id = ?`);
146
+ stmt.run(JSON.stringify(log), combatId);
147
+ return {
148
+ ...combat,
149
+ log,
150
+ };
151
+ }
152
+ export function removeParticipant(combatId, characterId) {
153
+ const combat = getCombat(combatId);
154
+ if (!combat)
155
+ return null;
156
+ const db = getDatabase();
157
+ const participants = combat.participants.map((p) => p.characterId === characterId ? { ...p, isActive: false } : p);
158
+ const stmt = db.prepare(`UPDATE combats SET participants = ? WHERE id = ?`);
159
+ stmt.run(JSON.stringify(participants), combatId);
160
+ // Check if combat should end
161
+ const activeCount = participants.filter((p) => p.isActive).length;
162
+ if (activeCount <= 1) {
163
+ return endCombat(combatId);
164
+ }
165
+ return {
166
+ ...combat,
167
+ participants,
168
+ };
169
+ }
170
+ export function endCombat(combatId) {
171
+ const combat = getCombat(combatId);
172
+ if (!combat)
173
+ return null;
174
+ const db = getDatabase();
175
+ const stmt = db.prepare(`UPDATE combats SET status = 'resolved' WHERE id = ?`);
176
+ stmt.run(combatId);
177
+ // Emit realtime event
178
+ gameEvents.emit({
179
+ type: "combat:ended",
180
+ gameId: combat.gameId,
181
+ entityId: combatId,
182
+ entityType: "combat",
183
+ timestamp: new Date().toISOString(),
184
+ });
185
+ return {
186
+ ...combat,
187
+ status: "resolved",
188
+ };
189
+ }
190
+ export function getCurrentCombatant(combatId) {
191
+ const combat = getCombat(combatId);
192
+ if (!combat || combat.status !== "active")
193
+ return null;
194
+ return combat.participants[combat.currentTurn]?.characterId || null;
195
+ }
@@ -0,0 +1,132 @@
1
+ import type { ConstraintKind, MonotonicDirection, ResourceConstraint } from "../types/index.js";
2
+ /**
3
+ * Declarative, opt-in, server-enforced invariants on `resources` rows.
4
+ *
5
+ * A resource with no declared constraint behaves exactly as it always has --
6
+ * out-of-bounds writes are silently clamped by clampValue() in resource.ts.
7
+ * Declaring a constraint here changes that contract for that one resource
8
+ * (or set of resources, for 'conserved') only.
9
+ *
10
+ * SCOPE NOTE: this layer covers the `resources` table only. Three other
11
+ * numeric write paths bypass it entirely and are NOT covered:
12
+ * - factions.resources (JSON blob; see modifyFactionResource/setFactionResource
13
+ * in src/tools/faction.ts -- no bounds, no history, silently deletes an
14
+ * entry when it hits <= 0)
15
+ * - characters.attributes (arbitrary numeric JSON, unvalidated)
16
+ * - relationships.value (its own separate system)
17
+ * A game author who needs a server-enforced invariant on a quantity must
18
+ * model that quantity as a `resources` row, not one of the above.
19
+ *
20
+ * 'conserved' is fully enforced: a resource that is a member of a declared
21
+ * 'conserved' constraint can no longer be written directly through
22
+ * update_resource_value (see checkResourceConstraints() below) -- it must go
23
+ * through transferResourceValue() in resource.ts, which moves value between
24
+ * exactly two members of the same set atomically. See the comment on
25
+ * transferResourceValue() for why an explicit transfer, rather than a
26
+ * balanced multi-resource write, was chosen.
27
+ */
28
+ /** Absolute tolerance for floating-point sum comparisons on 'conserved'
29
+ * constraints. IEEE 754 doubles cannot represent values like 0.1 exactly,
30
+ * so repeated addition/subtraction across many transfers can drift by a
31
+ * few ULPs. This is large enough to absorb that drift over realistic
32
+ * transfer volumes while still catching an actual logic bug (which would
33
+ * typically desync the sum by a whole `amount`, not a fraction of one). */
34
+ export declare const CONSERVED_SUM_EPSILON = 0.000001;
35
+ export declare class ConstraintViolationError extends Error {
36
+ readonly constraintKind: ConstraintKind;
37
+ readonly resourceId: string;
38
+ constructor(constraintKind: ConstraintKind, resourceId: string, message: string);
39
+ }
40
+ /**
41
+ * Declare a 'bounded' constraint: the resource's value must stay within its
42
+ * existing minValue/maxValue (set via create_resource/update_resource).
43
+ * Once declared, out-of-bounds writes through updateResourceValue() are
44
+ * REJECTED instead of silently clamped -- see checkResourceConstraints().
45
+ */
46
+ export declare function declareBoundedConstraint(params: {
47
+ gameId: string;
48
+ resourceId: string;
49
+ }): ResourceConstraint;
50
+ /**
51
+ * Declare a 'monotonic' constraint: the resource's value may only move in
52
+ * the given direction. 'increasing' means it may never decrease;
53
+ * 'decreasing' means it may never increase. Holding steady is always
54
+ * allowed.
55
+ */
56
+ export declare function declareMonotonicConstraint(params: {
57
+ gameId: string;
58
+ resourceId: string;
59
+ direction: MonotonicDirection;
60
+ }): ResourceConstraint;
61
+ /**
62
+ * Register a 'conserved' constraint: a set of resources that must always
63
+ * sum to a fixed total.
64
+ *
65
+ * Validation performed here, all rejecting rather than silently coercing:
66
+ * - at least two distinct resources
67
+ * - every resourceId must exist
68
+ * - `total` must be a finite number
69
+ * - no resource may already belong to another 'conserved' constraint --
70
+ * a resource can be a member of at most one conserved set at a time, so
71
+ * that transferResourceValue() (resource.ts) never has to guess which
72
+ * set a transfer touching that resource is supposed to preserve
73
+ * - the members' CURRENT values must already sum to `total`. This
74
+ * constraint records an existing invariant, it does not establish one --
75
+ * if the values don't already sum to `total`, that's almost always a
76
+ * caller mistake (wrong total, forgot a member), and silently rewriting
77
+ * resource values to make it true would hide that mistake.
78
+ *
79
+ * A resource may still separately hold a 'bounded' and/or 'monotonic'
80
+ * constraint alongside 'conserved' -- those are orthogonal and both remain
81
+ * enforced during transfers (see transferResourceValue()).
82
+ */
83
+ export declare function declareConservedConstraint(params: {
84
+ gameId: string;
85
+ resourceIds: string[];
86
+ total: number;
87
+ }): ResourceConstraint;
88
+ /** List constraints for a game, optionally filtered to ones governing a given resource. */
89
+ export declare function listConstraints(gameId: string, resourceId?: string): ResourceConstraint[];
90
+ /** All constraints (of any kind) that govern the given resource. */
91
+ export declare function getConstraintsForResource(resourceId: string): ResourceConstraint[];
92
+ /** Remove a constraint by id. Returns true if a row was deleted. */
93
+ export declare function removeConstraint(id: string): boolean;
94
+ /**
95
+ * Check whether an intended value change for a single resource would
96
+ * violate any 'bounded' or 'monotonic' constraint declared on it. Throws
97
+ * ConstraintViolationError on violation; returns void otherwise. Shared by
98
+ * checkResourceConstraints() (the single-resource write path) and
99
+ * transferResourceValue() (resource.ts; the two-resource conserved-transfer
100
+ * path) -- both paths must respect 'bounded'/'monotonic' the same way.
101
+ * Deliberately does not look at 'conserved' constraints; callers decide
102
+ * separately what to do about those (see checkResourceConstraints() below
103
+ * and transferResourceValue()).
104
+ */
105
+ export declare function checkBoundedAndMonotonicConstraints(resourceId: string, previousValue: number, intendedValue: number, bounds: {
106
+ minValue: number | null;
107
+ maxValue: number | null;
108
+ }): void;
109
+ /**
110
+ * Check whether an intended value change for a single resource would
111
+ * violate any constraint declared on it. Throws ConstraintViolationError on
112
+ * violation; returns void otherwise. Called from updateResourceValue() in
113
+ * resource.ts before the row is written.
114
+ *
115
+ * 'conserved' is handled specially here: ANY direct single-resource write to
116
+ * a conserved member is rejected, unconditionally, regardless of whether the
117
+ * particular delta would happen to preserve the total. The server cannot
118
+ * know where update_resource_value's counterpart delta should come from --
119
+ * writing one member without atomically adjusting another would silently
120
+ * break the set's invariant, which is exactly the failure this constraint
121
+ * exists to prevent. Use transfer_resource_value (transferResourceValue() in
122
+ * resource.ts) instead, which moves value between two members of the same
123
+ * set atomically.
124
+ */
125
+ export declare function checkResourceConstraints(resourceId: string, previousValue: number, intendedValue: number, bounds: {
126
+ minValue: number | null;
127
+ maxValue: number | null;
128
+ }): void;
129
+ /** All conserved constraints (of kind 'conserved') governing a resource,
130
+ * i.e. zero or one (declareConservedConstraint() rejects overlapping
131
+ * conserved membership, so a resource can belong to at most one). */
132
+ export declare function getConservedConstraintFor(resourceId: string): ResourceConstraint | null;