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,215 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase, withTransaction } from "../db/connection.js";
3
+ import { validateGameExists } from "./game.js";
4
+ import { updateResourceValue } from "./resource.js";
5
+ import { safeJsonParse } from "../utils/json.js";
6
+ function parseConsequence(raw) {
7
+ if (typeof raw !== "string" || raw.length === 0)
8
+ return null;
9
+ return safeJsonParse(raw, null);
10
+ }
11
+ export function createTimer(params) {
12
+ // Validate game exists to prevent orphaned records
13
+ validateGameExists(params.gameId);
14
+ const db = getDatabase();
15
+ const id = uuidv4();
16
+ const now = new Date().toISOString();
17
+ const timerType = params.timerType;
18
+ const direction = params.direction || (timerType === "stopwatch" ? "up" : "down");
19
+ const maxValue = params.maxValue ?? (timerType === "clock" ? 6 : null);
20
+ const currentValue = params.currentValue ?? (direction === "down" && maxValue ? maxValue : 0);
21
+ const triggerAt = params.triggerAt ?? (direction === "down" ? 0 : maxValue);
22
+ const consequence = params.consequence ?? null;
23
+ db.prepare(`
24
+ INSERT INTO timers (id, game_id, name, description, timer_type, current_value, max_value, direction, trigger_at, unit, visible_to_players, created_at, consequence)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
26
+ `).run(id, params.gameId, params.name, params.description || "", timerType, currentValue, maxValue, direction, triggerAt, params.unit || "tick", params.visibleToPlayers !== false ? 1 : 0, now, consequence ? JSON.stringify(consequence) : null);
27
+ return {
28
+ id,
29
+ gameId: params.gameId,
30
+ name: params.name,
31
+ description: params.description || "",
32
+ timerType,
33
+ currentValue,
34
+ maxValue,
35
+ direction,
36
+ triggerAt,
37
+ triggered: false,
38
+ unit: params.unit || "tick",
39
+ visibleToPlayers: params.visibleToPlayers !== false,
40
+ createdAt: now,
41
+ consequence,
42
+ };
43
+ }
44
+ export function getTimer(id) {
45
+ const db = getDatabase();
46
+ const row = db.prepare(`SELECT * FROM timers WHERE id = ?`).get(id);
47
+ if (!row)
48
+ return null;
49
+ return {
50
+ id: row.id,
51
+ gameId: row.game_id,
52
+ name: row.name,
53
+ description: row.description || "",
54
+ timerType: row.timer_type,
55
+ currentValue: row.current_value,
56
+ maxValue: row.max_value,
57
+ direction: row.direction,
58
+ triggerAt: row.trigger_at,
59
+ triggered: row.triggered === 1,
60
+ unit: row.unit,
61
+ visibleToPlayers: row.visible_to_players === 1,
62
+ createdAt: row.created_at,
63
+ consequence: parseConsequence(row.consequence),
64
+ };
65
+ }
66
+ export function updateTimer(id, updates) {
67
+ const db = getDatabase();
68
+ const current = getTimer(id);
69
+ if (!current)
70
+ return null;
71
+ const newName = updates.name ?? current.name;
72
+ const newDescription = updates.description ?? current.description;
73
+ const newMaxValue = updates.maxValue !== undefined ? updates.maxValue : current.maxValue;
74
+ const newTriggerAt = updates.triggerAt !== undefined ? updates.triggerAt : current.triggerAt;
75
+ const newUnit = updates.unit ?? current.unit;
76
+ const newVisible = updates.visibleToPlayers !== undefined ? updates.visibleToPlayers : current.visibleToPlayers;
77
+ db.prepare(`
78
+ UPDATE timers
79
+ SET name = ?, description = ?, max_value = ?, trigger_at = ?, unit = ?, visible_to_players = ?
80
+ WHERE id = ?
81
+ `).run(newName, newDescription, newMaxValue, newTriggerAt, newUnit, newVisible ? 1 : 0, id);
82
+ return {
83
+ ...current,
84
+ name: newName,
85
+ description: newDescription,
86
+ maxValue: newMaxValue,
87
+ triggerAt: newTriggerAt,
88
+ unit: newUnit,
89
+ visibleToPlayers: newVisible,
90
+ };
91
+ }
92
+ export function deleteTimer(id) {
93
+ const db = getDatabase();
94
+ const result = db.prepare(`DELETE FROM timers WHERE id = ?`).run(id);
95
+ return result.changes > 0;
96
+ }
97
+ export function listTimers(gameId, includeTriggered = false) {
98
+ const db = getDatabase();
99
+ let query = `SELECT * FROM timers WHERE game_id = ?`;
100
+ if (!includeTriggered) {
101
+ query += ` AND triggered = 0`;
102
+ }
103
+ query += ` ORDER BY name`;
104
+ const rows = db.prepare(query).all(gameId);
105
+ return rows.map(row => ({
106
+ id: row.id,
107
+ gameId: row.game_id,
108
+ name: row.name,
109
+ description: row.description || "",
110
+ timerType: row.timer_type,
111
+ currentValue: row.current_value,
112
+ maxValue: row.max_value,
113
+ direction: row.direction,
114
+ triggerAt: row.trigger_at,
115
+ triggered: row.triggered === 1,
116
+ unit: row.unit,
117
+ visibleToPlayers: row.visible_to_players === 1,
118
+ createdAt: row.created_at,
119
+ consequence: parseConsequence(row.consequence),
120
+ }));
121
+ }
122
+ export function tickTimer(id, amount = 1) {
123
+ const db = getDatabase();
124
+ const timer = getTimer(id);
125
+ if (!timer)
126
+ return null;
127
+ const previousValue = timer.currentValue;
128
+ let newValue;
129
+ if (timer.direction === "up") {
130
+ newValue = timer.currentValue + amount;
131
+ if (timer.maxValue !== null) {
132
+ newValue = Math.min(newValue, timer.maxValue);
133
+ }
134
+ }
135
+ else {
136
+ newValue = timer.currentValue - amount;
137
+ newValue = Math.max(newValue, 0);
138
+ }
139
+ // Check if triggered
140
+ let justTriggered = false;
141
+ if (!timer.triggered && timer.triggerAt !== null) {
142
+ if (timer.direction === "down" && newValue <= timer.triggerAt) {
143
+ justTriggered = true;
144
+ }
145
+ else if (timer.direction === "up" && newValue >= timer.triggerAt) {
146
+ justTriggered = true;
147
+ }
148
+ }
149
+ const newTriggeredFlag = justTriggered || timer.triggered;
150
+ const consequence = timer.consequence;
151
+ const consequenceApplied = justTriggered && consequence !== null;
152
+ // The tick itself (current_value + triggered flag) and the consequence
153
+ // application (when this tick is the one that crosses the trigger) land
154
+ // in a single transaction. If the consequence fails, NOTHING about this
155
+ // tick commits -- current_value and triggered stay exactly as they were,
156
+ // so the row is never left "expired-but-unapplied", and the next call to
157
+ // tickTimer() will recompute from the same starting point and retry.
158
+ if (consequenceApplied && consequence) {
159
+ withTransaction(() => {
160
+ db.prepare(`UPDATE timers SET current_value = ?, triggered = ? WHERE id = ?`)
161
+ .run(newValue, newTriggeredFlag ? 1 : 0, id);
162
+ const applied = updateResourceValue({
163
+ resourceId: consequence.resourceId,
164
+ mode: "delta",
165
+ value: consequence.delta,
166
+ reason: `Expiry consequence: ${timer.name}`,
167
+ });
168
+ if (!applied) {
169
+ throw new Error(`Resource '${consequence.resourceId}' not found for consequence of timer '${timer.name}' (${id})`);
170
+ }
171
+ });
172
+ }
173
+ else {
174
+ db.prepare(`UPDATE timers SET current_value = ?, triggered = ? WHERE id = ?`)
175
+ .run(newValue, newTriggeredFlag ? 1 : 0, id);
176
+ }
177
+ return {
178
+ timer: { ...timer, currentValue: newValue, triggered: newTriggeredFlag },
179
+ previousValue,
180
+ justTriggered,
181
+ consequenceApplied,
182
+ };
183
+ }
184
+ export function resetTimer(id) {
185
+ const db = getDatabase();
186
+ const timer = getTimer(id);
187
+ if (!timer)
188
+ return null;
189
+ const newValue = timer.direction === "down" && timer.maxValue !== null ? timer.maxValue : 0;
190
+ db.prepare(`UPDATE timers SET current_value = ?, triggered = 0 WHERE id = ?`)
191
+ .run(newValue, id);
192
+ return { ...timer, currentValue: newValue, triggered: false };
193
+ }
194
+ /**
195
+ * Modify a timer - tick or reset in a single call.
196
+ * Use mode: "tick" to advance by amount, mode: "reset" to reset to initial state.
197
+ */
198
+ export function modifyTimerState(id, params) {
199
+ const timer = getTimer(id);
200
+ if (!timer)
201
+ return null;
202
+ if (params.mode === "reset") {
203
+ const resetResult = resetTimer(id);
204
+ if (!resetResult)
205
+ return null;
206
+ return {
207
+ timer: resetResult,
208
+ previousValue: timer.currentValue,
209
+ justTriggered: false,
210
+ consequenceApplied: false,
211
+ };
212
+ }
213
+ // Tick mode
214
+ return tickTimer(id, params.amount ?? 1);
215
+ }
@@ -0,0 +1,78 @@
1
+ import type { Location, LocationProperties, Exit, ImageGeneration } from "../types/index.js";
2
+ export declare function createLocation(params: {
3
+ gameId: string;
4
+ name: string;
5
+ description: string;
6
+ properties?: Partial<LocationProperties>;
7
+ imageGen?: ImageGeneration;
8
+ }): Location;
9
+ export declare function getLocation(id: string): Location | null;
10
+ export declare function updateLocation(id: string, updates: {
11
+ name?: string;
12
+ description?: string;
13
+ properties?: Partial<LocationProperties>;
14
+ imageGen?: ImageGeneration | null;
15
+ }): Location | null;
16
+ export declare function listLocations(gameId: string): Location[];
17
+ export declare function deleteLocation(id: string): boolean;
18
+ export interface ConnectLocationsResult {
19
+ success: boolean;
20
+ fromLocation: {
21
+ id: string;
22
+ name: string;
23
+ };
24
+ toLocation: {
25
+ id: string;
26
+ name: string;
27
+ };
28
+ exitCreated: Exit;
29
+ reverseExitCreated: Exit | null;
30
+ bidirectional: boolean;
31
+ }
32
+ export declare function connectLocations(params: {
33
+ fromLocationId: string;
34
+ toLocationId: string;
35
+ fromDirection: string;
36
+ toDirection: string;
37
+ description?: string;
38
+ bidirectional?: boolean;
39
+ }): ConnectLocationsResult | null;
40
+ export declare function getExits(locationId: string): Exit[];
41
+ /**
42
+ * Find a location by name (case-insensitive, fuzzy match).
43
+ * Returns the best match or null if no reasonable match found.
44
+ */
45
+ export declare function getLocationByName(gameId: string, name: string): Location | null;
46
+ interface MapNode {
47
+ id: string;
48
+ name: string;
49
+ x: number;
50
+ y: number;
51
+ exits: Array<{
52
+ direction: string;
53
+ toId: string;
54
+ }>;
55
+ isCenter: boolean;
56
+ hasPlayer: boolean;
57
+ }
58
+ interface MapData {
59
+ nodes: MapNode[];
60
+ connections: Array<{
61
+ from: string;
62
+ to: string;
63
+ direction: string;
64
+ }>;
65
+ bounds: {
66
+ minX: number;
67
+ maxX: number;
68
+ minY: number;
69
+ maxY: number;
70
+ };
71
+ }
72
+ export declare function renderMap(gameId: string, options?: {
73
+ centerId?: string;
74
+ radius?: number;
75
+ showPlayerLocation?: boolean;
76
+ playerLocationId?: string;
77
+ }): MapData | null;
78
+ export {};
@@ -0,0 +1,331 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getDatabase, withTransaction } 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 createLocation(params) {
7
+ // Validate game exists to prevent orphaned records
8
+ validateGameExists(params.gameId);
9
+ const db = getDatabase();
10
+ const id = uuidv4();
11
+ const properties = {
12
+ exits: [],
13
+ features: [],
14
+ atmosphere: "",
15
+ ...params.properties,
16
+ };
17
+ const stmt = db.prepare(`
18
+ INSERT INTO locations (id, game_id, name, description, properties, image_gen)
19
+ VALUES (?, ?, ?, ?, ?, ?)
20
+ `);
21
+ stmt.run(id, params.gameId, params.name, params.description, JSON.stringify(properties), params.imageGen ? JSON.stringify(params.imageGen) : null);
22
+ return {
23
+ id,
24
+ gameId: params.gameId,
25
+ name: params.name,
26
+ description: params.description,
27
+ properties,
28
+ imageGen: params.imageGen || null,
29
+ };
30
+ }
31
+ export function getLocation(id) {
32
+ const db = getDatabase();
33
+ const stmt = db.prepare(`SELECT * FROM locations WHERE id = ?`);
34
+ const row = stmt.get(id);
35
+ if (!row)
36
+ return null;
37
+ return {
38
+ id: row.id,
39
+ gameId: row.game_id,
40
+ name: row.name,
41
+ description: row.description,
42
+ properties: safeJsonParse(row.properties, { exits: [], features: [], atmosphere: "" }),
43
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
44
+ };
45
+ }
46
+ export function updateLocation(id, updates) {
47
+ const db = getDatabase();
48
+ const current = getLocation(id);
49
+ if (!current)
50
+ return null;
51
+ const newName = updates.name ?? current.name;
52
+ const newDescription = updates.description ?? current.description;
53
+ const newProperties = updates.properties
54
+ ? { ...current.properties, ...updates.properties }
55
+ : current.properties;
56
+ const newImageGen = updates.imageGen !== undefined ? updates.imageGen : current.imageGen;
57
+ const stmt = db.prepare(`
58
+ UPDATE locations SET name = ?, description = ?, properties = ?, image_gen = ? WHERE id = ?
59
+ `);
60
+ stmt.run(newName, newDescription, JSON.stringify(newProperties), newImageGen ? JSON.stringify(newImageGen) : null, id);
61
+ const updated = {
62
+ ...current,
63
+ name: newName,
64
+ description: newDescription,
65
+ properties: newProperties,
66
+ imageGen: newImageGen,
67
+ };
68
+ // Emit realtime event
69
+ gameEvents.emit({
70
+ type: "location:updated",
71
+ gameId: current.gameId,
72
+ entityId: id,
73
+ entityType: "location",
74
+ timestamp: new Date().toISOString(),
75
+ data: { name: newName },
76
+ });
77
+ return updated;
78
+ }
79
+ export function listLocations(gameId) {
80
+ const db = getDatabase();
81
+ const stmt = db.prepare(`SELECT * FROM locations WHERE game_id = ?`);
82
+ const rows = stmt.all(gameId);
83
+ return rows.map((row) => ({
84
+ id: row.id,
85
+ gameId: row.game_id,
86
+ name: row.name,
87
+ description: row.description,
88
+ properties: safeJsonParse(row.properties, { exits: [], features: [], atmosphere: "" }),
89
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
90
+ }));
91
+ }
92
+ export function deleteLocation(id) {
93
+ const db = getDatabase();
94
+ const stmt = db.prepare(`DELETE FROM locations WHERE id = ?`);
95
+ const result = stmt.run(id);
96
+ return result.changes > 0;
97
+ }
98
+ export function connectLocations(params) {
99
+ const fromLocation = getLocation(params.fromLocationId);
100
+ const toLocation = getLocation(params.toLocationId);
101
+ if (!fromLocation || !toLocation)
102
+ return null;
103
+ // Add exit from first location to second
104
+ const exitFromTo = {
105
+ direction: params.fromDirection,
106
+ destinationId: params.toLocationId,
107
+ description: params.description,
108
+ };
109
+ fromLocation.properties.exits = fromLocation.properties.exits.filter((e) => e.direction !== params.fromDirection);
110
+ fromLocation.properties.exits.push(exitFromTo);
111
+ let reverseExitCreated = null;
112
+ const bidirectional = params.bidirectional !== false;
113
+ // Both updateLocation() calls below (the forward exit, and -- when
114
+ // bidirectional -- the reverse exit) must either both land or neither
115
+ // does. Otherwise a mid-operation failure leaves a one-way exit that the
116
+ // caller never asked for.
117
+ withTransaction(() => {
118
+ updateLocation(params.fromLocationId, {
119
+ properties: fromLocation.properties,
120
+ });
121
+ // If bidirectional, add reverse exit
122
+ if (bidirectional) {
123
+ const exitToFrom = {
124
+ direction: params.toDirection,
125
+ destinationId: params.fromLocationId,
126
+ description: params.description,
127
+ };
128
+ toLocation.properties.exits = toLocation.properties.exits.filter((e) => e.direction !== params.toDirection);
129
+ toLocation.properties.exits.push(exitToFrom);
130
+ updateLocation(params.toLocationId, {
131
+ properties: toLocation.properties,
132
+ });
133
+ reverseExitCreated = exitToFrom;
134
+ }
135
+ });
136
+ return {
137
+ success: true,
138
+ fromLocation: { id: fromLocation.id, name: fromLocation.name },
139
+ toLocation: { id: toLocation.id, name: toLocation.name },
140
+ exitCreated: exitFromTo,
141
+ reverseExitCreated,
142
+ bidirectional,
143
+ };
144
+ }
145
+ export function getExits(locationId) {
146
+ const location = getLocation(locationId);
147
+ if (!location)
148
+ return [];
149
+ return location.properties.exits;
150
+ }
151
+ /**
152
+ * Find a location by name (case-insensitive, fuzzy match).
153
+ * Returns the best match or null if no reasonable match found.
154
+ */
155
+ export function getLocationByName(gameId, name) {
156
+ const db = getDatabase();
157
+ const searchName = name.toLowerCase().trim();
158
+ // First try exact match (case-insensitive)
159
+ const exactMatch = db.prepare(`SELECT * FROM locations WHERE game_id = ? AND LOWER(name) = ?`).get(gameId, searchName);
160
+ if (exactMatch) {
161
+ return mapRowToLocation(exactMatch);
162
+ }
163
+ // Try contains match
164
+ const containsMatch = db.prepare(`SELECT * FROM locations WHERE game_id = ? AND LOWER(name) LIKE ?`).get(gameId, `%${searchName}%`);
165
+ if (containsMatch) {
166
+ return mapRowToLocation(containsMatch);
167
+ }
168
+ // Try matching just the last word (e.g., "Square" matches "Village Square")
169
+ const words = searchName.split(/\s+/);
170
+ if (words.length > 0) {
171
+ const lastWord = words[words.length - 1];
172
+ const lastWordMatch = db.prepare(`SELECT * FROM locations WHERE game_id = ? AND LOWER(name) LIKE ?`).get(gameId, `%${lastWord}%`);
173
+ if (lastWordMatch) {
174
+ return mapRowToLocation(lastWordMatch);
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ // Helper function to map database row to Location
180
+ function mapRowToLocation(row) {
181
+ return {
182
+ id: row.id,
183
+ gameId: row.game_id,
184
+ name: row.name,
185
+ description: row.description,
186
+ properties: safeJsonParse(row.properties, { exits: [], features: [], atmosphere: "" }),
187
+ imageGen: row.image_gen ? safeJsonParse(row.image_gen, null) : null,
188
+ };
189
+ }
190
+ // Direction to grid offset mapping
191
+ const DIRECTION_OFFSETS = {
192
+ north: { dx: 0, dy: -1 },
193
+ south: { dx: 0, dy: 1 },
194
+ east: { dx: 1, dy: 0 },
195
+ west: { dx: -1, dy: 0 },
196
+ northeast: { dx: 1, dy: -1 },
197
+ northwest: { dx: -1, dy: -1 },
198
+ southeast: { dx: 1, dy: 1 },
199
+ southwest: { dx: -1, dy: 1 },
200
+ n: { dx: 0, dy: -1 },
201
+ s: { dx: 0, dy: 1 },
202
+ e: { dx: 1, dy: 0 },
203
+ w: { dx: -1, dy: 0 },
204
+ ne: { dx: 1, dy: -1 },
205
+ nw: { dx: -1, dy: -1 },
206
+ se: { dx: 1, dy: 1 },
207
+ sw: { dx: -1, dy: 1 },
208
+ up: { dx: 0, dy: -1 },
209
+ down: { dx: 0, dy: 1 },
210
+ };
211
+ export function renderMap(gameId, options) {
212
+ const locations = listLocations(gameId);
213
+ if (locations.length === 0)
214
+ return null;
215
+ // Build location lookup
216
+ const locationMap = new Map(locations.map((l) => [l.id, l]));
217
+ // Determine center location
218
+ let centerId = options?.centerId;
219
+ if (!centerId) {
220
+ // Default to player location or first location
221
+ centerId = options?.playerLocationId || locations[0].id;
222
+ }
223
+ const centerLocation = locationMap.get(centerId);
224
+ if (!centerLocation)
225
+ return null;
226
+ // BFS to place locations on grid
227
+ const placed = new Map();
228
+ const queue = [];
229
+ const visited = new Set();
230
+ placed.set(centerId, { x: 0, y: 0 });
231
+ queue.push({ id: centerId, x: 0, y: 0, depth: 0 });
232
+ visited.add(centerId);
233
+ const maxRadius = options?.radius ?? Infinity;
234
+ while (queue.length > 0) {
235
+ const current = queue.shift();
236
+ if (!current)
237
+ break;
238
+ const location = locationMap.get(current.id);
239
+ if (!location)
240
+ continue;
241
+ // Don't explore beyond radius
242
+ if (current.depth >= maxRadius)
243
+ continue;
244
+ for (const exit of location.properties.exits) {
245
+ if (visited.has(exit.destinationId))
246
+ continue;
247
+ const offset = getDirectionOffset(exit.direction);
248
+ const newX = current.x + offset.dx;
249
+ const newY = current.y + offset.dy;
250
+ // Check if position is occupied, if so try to find nearby spot
251
+ let finalX = newX;
252
+ let finalY = newY;
253
+ if (isOccupied(placed, newX, newY)) {
254
+ const spot = findNearbySpot(placed, newX, newY);
255
+ finalX = spot.x;
256
+ finalY = spot.y;
257
+ }
258
+ placed.set(exit.destinationId, { x: finalX, y: finalY });
259
+ visited.add(exit.destinationId);
260
+ queue.push({ id: exit.destinationId, x: finalX, y: finalY, depth: current.depth + 1 });
261
+ }
262
+ }
263
+ // Build nodes and connections
264
+ const nodes = [];
265
+ const connections = [];
266
+ const connectionSet = new Set();
267
+ for (const [id, pos] of placed) {
268
+ const location = locationMap.get(id);
269
+ if (!location)
270
+ continue;
271
+ const exits = location.properties.exits
272
+ .filter((e) => placed.has(e.destinationId))
273
+ .map((e) => ({ direction: e.direction, toId: e.destinationId }));
274
+ nodes.push({
275
+ id,
276
+ name: location.name,
277
+ x: pos.x,
278
+ y: pos.y,
279
+ exits,
280
+ isCenter: id === centerId,
281
+ hasPlayer: id === options?.playerLocationId,
282
+ });
283
+ // Add connections (deduplicated)
284
+ for (const exit of exits) {
285
+ const key1 = `${id}-${exit.toId}`;
286
+ const key2 = `${exit.toId}-${id}`;
287
+ if (!connectionSet.has(key1) && !connectionSet.has(key2)) {
288
+ connections.push({ from: id, to: exit.toId, direction: exit.direction });
289
+ connectionSet.add(key1);
290
+ }
291
+ }
292
+ }
293
+ // Calculate bounds
294
+ const xs = nodes.map((n) => n.x);
295
+ const ys = nodes.map((n) => n.y);
296
+ const bounds = {
297
+ minX: Math.min(...xs),
298
+ maxX: Math.max(...xs),
299
+ minY: Math.min(...ys),
300
+ maxY: Math.max(...ys),
301
+ };
302
+ return { nodes, connections, bounds };
303
+ }
304
+ function getDirectionOffset(direction) {
305
+ const normalized = direction.toLowerCase().trim();
306
+ return DIRECTION_OFFSETS[normalized] || { dx: 0, dy: 0 };
307
+ }
308
+ function isOccupied(placed, x, y) {
309
+ for (const pos of placed.values()) {
310
+ if (pos.x === x && pos.y === y)
311
+ return true;
312
+ }
313
+ return false;
314
+ }
315
+ function findNearbySpot(placed, x, y) {
316
+ // Spiral outward to find empty spot
317
+ const offsets = [
318
+ [1, 0], [-1, 0], [0, 1], [0, -1],
319
+ [1, 1], [-1, 1], [1, -1], [-1, -1],
320
+ [2, 0], [-2, 0], [0, 2], [0, -2],
321
+ ];
322
+ for (const [dx, dy] of offsets) {
323
+ const newX = x + dx;
324
+ const newY = y + dy;
325
+ if (!isOccupied(placed, newX, newY)) {
326
+ return { x: newX, y: newY };
327
+ }
328
+ }
329
+ // Fallback: just offset by a lot
330
+ return { x: x + 3, y };
331
+ }