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