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,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;
|