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,374 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { getDatabase, withTransaction } from "../db/connection.js";
|
|
3
|
+
import { validateGameExists } from "./game.js";
|
|
4
|
+
import { checkResourceConstraints, checkBoundedAndMonotonicConstraints, getConservedConstraintFor, ConstraintViolationError, CONSERVED_SUM_EPSILON, } from "./constraint.js";
|
|
5
|
+
function clampValue(value, minValue, maxValue) {
|
|
6
|
+
let result = value;
|
|
7
|
+
if (minValue !== null)
|
|
8
|
+
result = Math.max(result, minValue);
|
|
9
|
+
if (maxValue !== null)
|
|
10
|
+
result = Math.min(result, maxValue);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reject (never clamp) a transfer leg that would push `resource` outside
|
|
15
|
+
* its own minValue/maxValue. Used only by transferResourceValue() -- see
|
|
16
|
+
* the doc comment there for why a transfer never clamps, even when the
|
|
17
|
+
* resource has no separately-declared 'bounded' constraint.
|
|
18
|
+
*/
|
|
19
|
+
function assertWithinBoundsForTransfer(resource, intendedValue, role) {
|
|
20
|
+
if (resource.minValue !== null && intendedValue < resource.minValue) {
|
|
21
|
+
throw new ConstraintViolationError("conserved", resource.id, `Transfer rejected: '${resource.name}' (${resource.id}) would go below its minimum value ` +
|
|
22
|
+
`(${resource.minValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
|
|
23
|
+
`clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
|
|
24
|
+
`Choose a smaller amount.`);
|
|
25
|
+
}
|
|
26
|
+
if (resource.maxValue !== null && intendedValue > resource.maxValue) {
|
|
27
|
+
throw new ConstraintViolationError("conserved", resource.id, `Transfer rejected: '${resource.name}' (${resource.id}) would exceed its maximum value ` +
|
|
28
|
+
`(${resource.maxValue}) as the ${role} of this transfer. transfer_resource_value never clamps -- ` +
|
|
29
|
+
`clamping one side of a transfer would apply an uneven delta and silently create or destroy value. ` +
|
|
30
|
+
`Choose a smaller amount.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function createResource(params) {
|
|
34
|
+
// Validate game exists to prevent orphaned records
|
|
35
|
+
validateGameExists(params.gameId);
|
|
36
|
+
const db = getDatabase();
|
|
37
|
+
const id = uuidv4();
|
|
38
|
+
const now = new Date().toISOString();
|
|
39
|
+
const ownerId = params.ownerType === "game" ? null : (params.ownerId || null);
|
|
40
|
+
const initialValue = params.value ?? 0;
|
|
41
|
+
const minValue = params.minValue ?? null;
|
|
42
|
+
const maxValue = params.maxValue ?? null;
|
|
43
|
+
const value = clampValue(initialValue, minValue, maxValue);
|
|
44
|
+
const stmt = db.prepare(`
|
|
45
|
+
INSERT INTO resources (id, game_id, owner_id, owner_type, name, description, category, value, min_value, max_value, created_at)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
`);
|
|
48
|
+
stmt.run(id, params.gameId, ownerId, params.ownerType, params.name, params.description || "", params.category || null, value, minValue, maxValue, now);
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
gameId: params.gameId,
|
|
52
|
+
ownerId,
|
|
53
|
+
ownerType: params.ownerType,
|
|
54
|
+
name: params.name,
|
|
55
|
+
description: params.description || "",
|
|
56
|
+
category: params.category || null,
|
|
57
|
+
value,
|
|
58
|
+
minValue,
|
|
59
|
+
maxValue,
|
|
60
|
+
createdAt: now,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function getResource(id) {
|
|
64
|
+
const db = getDatabase();
|
|
65
|
+
const stmt = db.prepare(`SELECT * FROM resources WHERE id = ?`);
|
|
66
|
+
const row = stmt.get(id);
|
|
67
|
+
if (!row)
|
|
68
|
+
return null;
|
|
69
|
+
return {
|
|
70
|
+
id: row.id,
|
|
71
|
+
gameId: row.game_id,
|
|
72
|
+
ownerId: row.owner_id,
|
|
73
|
+
ownerType: row.owner_type,
|
|
74
|
+
name: row.name,
|
|
75
|
+
description: row.description,
|
|
76
|
+
category: row.category,
|
|
77
|
+
value: row.value,
|
|
78
|
+
minValue: row.min_value,
|
|
79
|
+
maxValue: row.max_value,
|
|
80
|
+
createdAt: row.created_at,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function updateResource(id, updates) {
|
|
84
|
+
const db = getDatabase();
|
|
85
|
+
const current = getResource(id);
|
|
86
|
+
if (!current)
|
|
87
|
+
return null;
|
|
88
|
+
const newName = updates.name ?? current.name;
|
|
89
|
+
const newDescription = updates.description ?? current.description;
|
|
90
|
+
const newCategory = updates.category !== undefined ? updates.category : current.category;
|
|
91
|
+
const newMinValue = updates.minValue !== undefined ? updates.minValue : current.minValue;
|
|
92
|
+
const newMaxValue = updates.maxValue !== undefined ? updates.maxValue : current.maxValue;
|
|
93
|
+
// Re-clamp value if bounds changed
|
|
94
|
+
const newValue = clampValue(current.value, newMinValue, newMaxValue);
|
|
95
|
+
// A conserved member's value may ONLY change via transferResourceValue(),
|
|
96
|
+
// which applies an equal-and-opposite delta to another member in the same
|
|
97
|
+
// atomic write. Re-clamping here would change this resource's value on
|
|
98
|
+
// its own -- silently, with no counterpart adjustment -- which is exactly
|
|
99
|
+
// the kind of isolated write that breaks the set's total. Reject instead;
|
|
100
|
+
// the caller can still change bounds that don't affect the current value.
|
|
101
|
+
if (newValue !== current.value) {
|
|
102
|
+
const conserved = getConservedConstraintFor(id);
|
|
103
|
+
if (conserved) {
|
|
104
|
+
throw new ConstraintViolationError("conserved", id, `Resource '${id}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ` +
|
|
105
|
+
`and its value cannot be changed by update_resource, including indirectly by narrowing minValue/maxValue ` +
|
|
106
|
+
`so the current value would be reclamped. Rejected instead of silently changing the value -- use ` +
|
|
107
|
+
`transfer_resource_value if the value itself needs to move, or choose bounds that don't affect the current value.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const stmt = db.prepare(`
|
|
111
|
+
UPDATE resources
|
|
112
|
+
SET name = ?, description = ?, category = ?, min_value = ?, max_value = ?, value = ?
|
|
113
|
+
WHERE id = ?
|
|
114
|
+
`);
|
|
115
|
+
stmt.run(newName, newDescription, newCategory, newMinValue, newMaxValue, newValue, id);
|
|
116
|
+
return {
|
|
117
|
+
...current,
|
|
118
|
+
name: newName,
|
|
119
|
+
description: newDescription,
|
|
120
|
+
category: newCategory,
|
|
121
|
+
minValue: newMinValue,
|
|
122
|
+
maxValue: newMaxValue,
|
|
123
|
+
value: newValue,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function deleteResource(id) {
|
|
127
|
+
// Deleting a conserved member would shrink the set out from under its
|
|
128
|
+
// declared total with no counterpart adjustment (and, via ON DELETE
|
|
129
|
+
// CASCADE on resource_constraint_members, would silently remove it from
|
|
130
|
+
// the set rather than raise any error). Reject instead: the caller must
|
|
131
|
+
// remove_resource_constraint first if the set itself is being redefined.
|
|
132
|
+
const conserved = getConservedConstraintFor(id);
|
|
133
|
+
if (conserved) {
|
|
134
|
+
throw new ConstraintViolationError("conserved", id, `Resource '${id}' is a member of a 'conserved' constraint (id '${conserved.id}', total ${conserved.total}) ` +
|
|
135
|
+
`and cannot be deleted while that constraint exists -- deleting it would shrink the set below its declared ` +
|
|
136
|
+
`total with no way to keep the invariant true. Remove the constraint first with remove_resource_constraint ` +
|
|
137
|
+
`if the set is being redefined.`);
|
|
138
|
+
}
|
|
139
|
+
const db = getDatabase();
|
|
140
|
+
const stmt = db.prepare(`DELETE FROM resources WHERE id = ?`);
|
|
141
|
+
const result = stmt.run(id);
|
|
142
|
+
return result.changes > 0;
|
|
143
|
+
}
|
|
144
|
+
export function listResources(gameId, filter) {
|
|
145
|
+
const db = getDatabase();
|
|
146
|
+
let query = `SELECT * FROM resources WHERE game_id = ?`;
|
|
147
|
+
const params = [gameId];
|
|
148
|
+
if (filter?.ownerType !== undefined) {
|
|
149
|
+
query += ` AND owner_type = ?`;
|
|
150
|
+
params.push(filter.ownerType);
|
|
151
|
+
}
|
|
152
|
+
if (filter?.ownerId !== undefined) {
|
|
153
|
+
query += ` AND owner_id = ?`;
|
|
154
|
+
params.push(filter.ownerId);
|
|
155
|
+
}
|
|
156
|
+
if (filter?.category !== undefined) {
|
|
157
|
+
query += ` AND category = ?`;
|
|
158
|
+
params.push(filter.category);
|
|
159
|
+
}
|
|
160
|
+
query += ` ORDER BY name`;
|
|
161
|
+
const stmt = db.prepare(query);
|
|
162
|
+
const rows = stmt.all(...params);
|
|
163
|
+
return rows.map((row) => ({
|
|
164
|
+
id: row.id,
|
|
165
|
+
gameId: row.game_id,
|
|
166
|
+
ownerId: row.owner_id,
|
|
167
|
+
ownerType: row.owner_type,
|
|
168
|
+
name: row.name,
|
|
169
|
+
description: row.description,
|
|
170
|
+
category: row.category,
|
|
171
|
+
value: row.value,
|
|
172
|
+
minValue: row.min_value,
|
|
173
|
+
maxValue: row.max_value,
|
|
174
|
+
createdAt: row.created_at,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
function logChange(resourceId, previousValue, newValue, reason) {
|
|
178
|
+
const db = getDatabase();
|
|
179
|
+
const id = uuidv4();
|
|
180
|
+
const now = new Date().toISOString();
|
|
181
|
+
const delta = newValue - previousValue;
|
|
182
|
+
const stmt = db.prepare(`
|
|
183
|
+
INSERT INTO resource_history (id, resource_id, previous_value, new_value, delta, reason, timestamp)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
185
|
+
`);
|
|
186
|
+
stmt.run(id, resourceId, previousValue, newValue, delta, reason, now);
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
resourceId,
|
|
190
|
+
previousValue,
|
|
191
|
+
newValue,
|
|
192
|
+
delta,
|
|
193
|
+
reason,
|
|
194
|
+
timestamp: now,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Update a resource's value - either by delta or absolute set.
|
|
199
|
+
* Use mode: "delta" to add/subtract, mode: "set" to set an absolute value.
|
|
200
|
+
*/
|
|
201
|
+
export function updateResourceValue(params) {
|
|
202
|
+
const resource = getResource(params.resourceId);
|
|
203
|
+
if (!resource)
|
|
204
|
+
return null;
|
|
205
|
+
const previousValue = resource.value;
|
|
206
|
+
const intendedValue = params.mode === "delta" ? previousValue + params.value : params.value;
|
|
207
|
+
// Opt-in constraint enforcement: throws ConstraintViolationError and
|
|
208
|
+
// leaves the row untouched if this resource has a declared 'bounded' or
|
|
209
|
+
// 'monotonic' constraint that `intendedValue` would violate. Resources
|
|
210
|
+
// with no declared constraint are unaffected by this call and fall
|
|
211
|
+
// through to the existing clamp behavior below, unchanged.
|
|
212
|
+
checkResourceConstraints(params.resourceId, previousValue, intendedValue, {
|
|
213
|
+
minValue: resource.minValue,
|
|
214
|
+
maxValue: resource.maxValue,
|
|
215
|
+
});
|
|
216
|
+
const newValue = clampValue(intendedValue, resource.minValue, resource.maxValue);
|
|
217
|
+
// The value update and its resource_history row must land together --
|
|
218
|
+
// otherwise a failure between the two leaves a changed value with no
|
|
219
|
+
// audit trail explaining why it changed.
|
|
220
|
+
const change = withTransaction(() => {
|
|
221
|
+
const db = getDatabase();
|
|
222
|
+
const stmt = db.prepare(`UPDATE resources SET value = ? WHERE id = ?`);
|
|
223
|
+
stmt.run(newValue, params.resourceId);
|
|
224
|
+
return logChange(params.resourceId, previousValue, newValue, params.reason || null);
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
resource: { ...resource, value: newValue },
|
|
228
|
+
change,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Move `amount` from one resource to another, atomically. This is the ONLY
|
|
233
|
+
* write path for a resource that is a member of a declared 'conserved'
|
|
234
|
+
* constraint -- checkResourceConstraints() (constraint.ts) rejects
|
|
235
|
+
* update_resource_value against such a resource specifically because a
|
|
236
|
+
* single-resource write can't express where the counterpart delta comes
|
|
237
|
+
* from. This function is that counterpart-carrying write.
|
|
238
|
+
*
|
|
239
|
+
* WHY AN EXPLICIT TRANSFER TOOL, NOT A BALANCED MULTI-RESOURCE WRITE:
|
|
240
|
+
* A single-resource write against a conserved member is ambiguous -- the
|
|
241
|
+
* server has no way to infer where the offsetting delta should come from.
|
|
242
|
+
* Two designs resolve that ambiguity:
|
|
243
|
+
* (a) an explicit transfer(from, to, amount) tool, or
|
|
244
|
+
* (b) a "balanced write" tool taking an arbitrary { resourceId: delta }
|
|
245
|
+
* map, rejecting the whole call unless every conserved set's deltas
|
|
246
|
+
* sum to zero.
|
|
247
|
+
* (b) is more general -- it can reshuffle N members in one call -- but
|
|
248
|
+
* offloads real bookkeeping onto the caller: an LLM has to enumerate every
|
|
249
|
+
* affected resourceId, get every delta's sign right, and get the
|
|
250
|
+
* grouping-by-constraint-set right, all in one shot with no partial-progress
|
|
251
|
+
* checkpoint if it gets any of that wrong. (a) covers the overwhelming
|
|
252
|
+
* majority of real game actions directly ("trade 10 grain for gold", "the
|
|
253
|
+
* treasury pays the population") and composes: an N-member reshuffle is just
|
|
254
|
+
* several transfers, which the caller can still wrap in one withTransaction()
|
|
255
|
+
* if it needs to be all-or-nothing (see the "chains two transfers" tests in
|
|
256
|
+
* conserved.test.ts). That composability, plus the much smaller surface for
|
|
257
|
+
* a caller to get wrong, is why the narrower primitive was chosen.
|
|
258
|
+
*
|
|
259
|
+
* Scope is deliberately narrow: fromResourceId and toResourceId must already
|
|
260
|
+
* be members of the SAME declared 'conserved' constraint. This is not a
|
|
261
|
+
* general "move value between any two resources" tool -- for anything not
|
|
262
|
+
* under a 'conserved' constraint, update_resource_value remains the right
|
|
263
|
+
* tool (see checkResourceConstraints()). Keeping the two write paths
|
|
264
|
+
* mutually exclusive per resource means which one to use is never
|
|
265
|
+
* ambiguous.
|
|
266
|
+
*
|
|
267
|
+
* Never clamps. Clamping one side of a transfer would apply an uneven delta
|
|
268
|
+
* -- the source would lose less (or the destination gain less) than the
|
|
269
|
+
* other side moved by, silently creating or destroying value -- so any
|
|
270
|
+
* bound violation on either side rejects the whole transfer instead,
|
|
271
|
+
* regardless of whether a 'bounded' constraint is separately declared.
|
|
272
|
+
*/
|
|
273
|
+
export function transferResourceValue(params) {
|
|
274
|
+
if (params.fromResourceId === params.toResourceId) {
|
|
275
|
+
throw new Error(`Cannot transfer a resource to itself (fromResourceId and toResourceId are both '${params.fromResourceId}').`);
|
|
276
|
+
}
|
|
277
|
+
if (!Number.isFinite(params.amount)) {
|
|
278
|
+
throw new Error(`Transfer amount must be a finite number; got ${params.amount}.`);
|
|
279
|
+
}
|
|
280
|
+
if (params.amount < 0) {
|
|
281
|
+
throw new Error(`Transfer amount must be >= 0 (got ${params.amount}); swap fromResourceId and toResourceId to reverse ` +
|
|
282
|
+
`direction instead of using a negative amount.`);
|
|
283
|
+
}
|
|
284
|
+
const from = getResource(params.fromResourceId);
|
|
285
|
+
if (!from) {
|
|
286
|
+
throw new Error(`Resource '${params.fromResourceId}' not found.`);
|
|
287
|
+
}
|
|
288
|
+
const to = getResource(params.toResourceId);
|
|
289
|
+
if (!to) {
|
|
290
|
+
throw new Error(`Resource '${params.toResourceId}' not found.`);
|
|
291
|
+
}
|
|
292
|
+
const fromConstraint = getConservedConstraintFor(from.id);
|
|
293
|
+
const toConstraint = getConservedConstraintFor(to.id);
|
|
294
|
+
if (!fromConstraint || !toConstraint || fromConstraint.id !== toConstraint.id) {
|
|
295
|
+
const details = [];
|
|
296
|
+
if (!fromConstraint)
|
|
297
|
+
details.push(`'${from.name}' (${from.id}) is not a member of any 'conserved' constraint.`);
|
|
298
|
+
if (!toConstraint)
|
|
299
|
+
details.push(`'${to.name}' (${to.id}) is not a member of any 'conserved' constraint.`);
|
|
300
|
+
if (fromConstraint && toConstraint && fromConstraint.id !== toConstraint.id) {
|
|
301
|
+
details.push(`They belong to different 'conserved' constraints ('${fromConstraint.id}' and '${toConstraint.id}').`);
|
|
302
|
+
}
|
|
303
|
+
throw new ConstraintViolationError("conserved", from.id, `transfer_resource_value requires fromResourceId and toResourceId to both be members of the same declared ` +
|
|
304
|
+
`'conserved' constraint -- moving value between resources outside a shared conserved set would change ` +
|
|
305
|
+
`each side's total independently, which is what update_resource_value is for. ${details.join(" ")}`);
|
|
306
|
+
}
|
|
307
|
+
const fromPrev = from.value;
|
|
308
|
+
const toPrev = to.value;
|
|
309
|
+
const fromIntended = fromPrev - params.amount;
|
|
310
|
+
const toIntended = toPrev + params.amount;
|
|
311
|
+
// Bounded/monotonic constraints, if separately declared, apply during a
|
|
312
|
+
// transfer exactly as they do during a direct write.
|
|
313
|
+
checkBoundedAndMonotonicConstraints(from.id, fromPrev, fromIntended, {
|
|
314
|
+
minValue: from.minValue,
|
|
315
|
+
maxValue: from.maxValue,
|
|
316
|
+
});
|
|
317
|
+
checkBoundedAndMonotonicConstraints(to.id, toPrev, toIntended, {
|
|
318
|
+
minValue: to.minValue,
|
|
319
|
+
maxValue: to.maxValue,
|
|
320
|
+
});
|
|
321
|
+
// Never clamp (see doc comment above): reject outright if either side's
|
|
322
|
+
// own minValue/maxValue would be violated, even without a declared
|
|
323
|
+
// 'bounded' constraint.
|
|
324
|
+
assertWithinBoundsForTransfer(from, fromIntended, "source");
|
|
325
|
+
assertWithinBoundsForTransfer(to, toIntended, "destination");
|
|
326
|
+
const reason = params.reason || null;
|
|
327
|
+
const constraintId = fromConstraint.id;
|
|
328
|
+
const declaredTotal = fromConstraint.total ?? 0;
|
|
329
|
+
const memberIds = fromConstraint.resourceIds;
|
|
330
|
+
return withTransaction(() => {
|
|
331
|
+
const db = getDatabase();
|
|
332
|
+
db.prepare(`UPDATE resources SET value = ? WHERE id = ?`).run(fromIntended, from.id);
|
|
333
|
+
db.prepare(`UPDATE resources SET value = ? WHERE id = ?`).run(toIntended, to.id);
|
|
334
|
+
const fromChange = logChange(from.id, fromPrev, fromIntended, reason);
|
|
335
|
+
const toChange = logChange(to.id, toPrev, toIntended, reason);
|
|
336
|
+
// Defense in depth: re-read every member of the set (inside this same
|
|
337
|
+
// transaction, so this sees the writes above) and assert it still sums
|
|
338
|
+
// to the declared total. The primary guarantee is structural (an equal
|
|
339
|
+
// and opposite delta, above) -- this turns any future bug in this
|
|
340
|
+
// function, or a schema change that opens another write path around it,
|
|
341
|
+
// into a loud rollback instead of a silently wrong total.
|
|
342
|
+
const currentSum = memberIds.reduce((sum, id) => sum + (getResource(id)?.value ?? 0), 0);
|
|
343
|
+
if (Math.abs(currentSum - declaredTotal) > CONSERVED_SUM_EPSILON) {
|
|
344
|
+
throw new Error(`Invariant check failed after transfer: conserved constraint '${constraintId}' members now sum to ` +
|
|
345
|
+
`${currentSum}, expected ${declaredTotal}. Rolling back.`);
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
from: { ...from, value: fromIntended },
|
|
349
|
+
to: { ...to, value: toIntended },
|
|
350
|
+
fromChange,
|
|
351
|
+
toChange,
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
export function getResourceHistory(resourceId, limit) {
|
|
356
|
+
const db = getDatabase();
|
|
357
|
+
let query = `SELECT * FROM resource_history WHERE resource_id = ? ORDER BY timestamp DESC`;
|
|
358
|
+
const params = [resourceId];
|
|
359
|
+
if (limit !== undefined) {
|
|
360
|
+
query += ` LIMIT ?`;
|
|
361
|
+
params.push(limit);
|
|
362
|
+
}
|
|
363
|
+
const stmt = db.prepare(query);
|
|
364
|
+
const rows = stmt.all(...params);
|
|
365
|
+
return rows.map((row) => ({
|
|
366
|
+
id: row.id,
|
|
367
|
+
resourceId: row.resource_id,
|
|
368
|
+
previousValue: row.previous_value,
|
|
369
|
+
newValue: row.new_value,
|
|
370
|
+
delta: row.delta,
|
|
371
|
+
reason: row.reason,
|
|
372
|
+
timestamp: row.timestamp,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RuleSystem } from "../types/index.js";
|
|
2
|
+
export declare function setRules(gameId: string, rules: RuleSystem): boolean;
|
|
3
|
+
export declare function getRules(gameId: string): RuleSystem | null;
|
|
4
|
+
export declare function updateRules(gameId: string, updates: Partial<RuleSystem>): RuleSystem | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getDatabase } from "../db/connection.js";
|
|
2
|
+
import { safeJsonParseOrNull } from "../utils/json.js";
|
|
3
|
+
export function setRules(gameId, rules) {
|
|
4
|
+
const db = getDatabase();
|
|
5
|
+
const now = new Date().toISOString();
|
|
6
|
+
const stmt = db.prepare(`
|
|
7
|
+
UPDATE games SET rules = ?, updated_at = ? WHERE id = ?
|
|
8
|
+
`);
|
|
9
|
+
const result = stmt.run(JSON.stringify(rules), now, gameId);
|
|
10
|
+
return result.changes > 0;
|
|
11
|
+
}
|
|
12
|
+
export function getRules(gameId) {
|
|
13
|
+
const db = getDatabase();
|
|
14
|
+
const stmt = db.prepare(`SELECT rules FROM games WHERE id = ?`);
|
|
15
|
+
const row = stmt.get(gameId);
|
|
16
|
+
if (!row || !row.rules)
|
|
17
|
+
return null;
|
|
18
|
+
return safeJsonParseOrNull(row.rules);
|
|
19
|
+
}
|
|
20
|
+
export function updateRules(gameId, updates) {
|
|
21
|
+
const currentRules = getRules(gameId);
|
|
22
|
+
if (!currentRules)
|
|
23
|
+
return null;
|
|
24
|
+
const updatedRules = {
|
|
25
|
+
...currentRules,
|
|
26
|
+
...updates,
|
|
27
|
+
};
|
|
28
|
+
setRules(gameId, updatedRules);
|
|
29
|
+
return updatedRules;
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Secret } from "../types/index.js";
|
|
2
|
+
export declare function createSecret(params: {
|
|
3
|
+
gameId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
category?: string;
|
|
7
|
+
relatedEntityId?: string;
|
|
8
|
+
relatedEntityType?: string;
|
|
9
|
+
clues?: string[];
|
|
10
|
+
}): Secret;
|
|
11
|
+
export declare function getSecret(id: string): Secret | null;
|
|
12
|
+
export declare function updateSecret(id: string, updates: {
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
category?: string | null;
|
|
16
|
+
relatedEntityId?: string | null;
|
|
17
|
+
relatedEntityType?: string | null;
|
|
18
|
+
}): Secret | null;
|
|
19
|
+
export declare function deleteSecret(id: string): boolean;
|
|
20
|
+
export declare function listSecrets(gameId: string, filter?: {
|
|
21
|
+
category?: string;
|
|
22
|
+
relatedEntityId?: string;
|
|
23
|
+
isPublic?: boolean;
|
|
24
|
+
knownBy?: string;
|
|
25
|
+
}): Secret[];
|
|
26
|
+
/**
|
|
27
|
+
* Modify secret visibility - reveal to specific characters or make public in a single call.
|
|
28
|
+
*/
|
|
29
|
+
export declare function modifySecretVisibility(secretId: string, params: {
|
|
30
|
+
revealTo?: string[];
|
|
31
|
+
makePublic?: boolean;
|
|
32
|
+
}): {
|
|
33
|
+
secret: Secret;
|
|
34
|
+
revealedToNew: string[];
|
|
35
|
+
madePublic: boolean;
|
|
36
|
+
} | null;
|
|
37
|
+
export declare function addClue(secretId: string, clue: string): Secret | null;
|
|
38
|
+
export declare function removeClue(secretId: string, index: number): Secret | null;
|
|
39
|
+
export interface CharacterKnowledge {
|
|
40
|
+
characterId: string;
|
|
41
|
+
knownSecrets: Secret[];
|
|
42
|
+
cluesFound: Array<{
|
|
43
|
+
secret: Secret;
|
|
44
|
+
clueIndex: number;
|
|
45
|
+
clue: string;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
export declare function getCharacterKnowledge(gameId: string, characterId: string): CharacterKnowledge;
|
|
49
|
+
export declare function checkKnowsSecret(secretId: string, characterId: string): boolean;
|
|
@@ -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 { validateGameExists } from "./game.js";
|
|
5
|
+
export function createSecret(params) {
|
|
6
|
+
// Validate game exists to prevent orphaned records
|
|
7
|
+
validateGameExists(params.gameId);
|
|
8
|
+
const db = getDatabase();
|
|
9
|
+
const id = uuidv4();
|
|
10
|
+
const now = new Date().toISOString();
|
|
11
|
+
db.prepare(`
|
|
12
|
+
INSERT INTO secrets (id, game_id, name, description, category, related_entity_id, related_entity_type, clues, created_at)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
14
|
+
`).run(id, params.gameId, params.name, params.description, params.category || null, params.relatedEntityId || null, params.relatedEntityType || null, JSON.stringify(params.clues || []), now);
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
gameId: params.gameId,
|
|
18
|
+
name: params.name,
|
|
19
|
+
description: params.description,
|
|
20
|
+
category: params.category || null,
|
|
21
|
+
relatedEntityId: params.relatedEntityId || null,
|
|
22
|
+
relatedEntityType: params.relatedEntityType || null,
|
|
23
|
+
revealedTo: [],
|
|
24
|
+
isPublic: false,
|
|
25
|
+
clues: params.clues || [],
|
|
26
|
+
createdAt: now,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function getSecret(id) {
|
|
30
|
+
const db = getDatabase();
|
|
31
|
+
const row = db.prepare(`SELECT * FROM secrets WHERE id = ?`).get(id);
|
|
32
|
+
if (!row)
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
id: row.id,
|
|
36
|
+
gameId: row.game_id,
|
|
37
|
+
name: row.name,
|
|
38
|
+
description: row.description,
|
|
39
|
+
category: row.category,
|
|
40
|
+
relatedEntityId: row.related_entity_id,
|
|
41
|
+
relatedEntityType: row.related_entity_type,
|
|
42
|
+
revealedTo: safeJsonParse(row.revealed_to || "[]", []),
|
|
43
|
+
isPublic: row.is_public === 1,
|
|
44
|
+
clues: safeJsonParse(row.clues || "[]", []),
|
|
45
|
+
createdAt: row.created_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function updateSecret(id, updates) {
|
|
49
|
+
const db = getDatabase();
|
|
50
|
+
const current = getSecret(id);
|
|
51
|
+
if (!current)
|
|
52
|
+
return null;
|
|
53
|
+
const newName = updates.name ?? current.name;
|
|
54
|
+
const newDescription = updates.description ?? current.description;
|
|
55
|
+
const newCategory = updates.category !== undefined ? updates.category : current.category;
|
|
56
|
+
const newRelatedId = updates.relatedEntityId !== undefined ? updates.relatedEntityId : current.relatedEntityId;
|
|
57
|
+
const newRelatedType = updates.relatedEntityType !== undefined ? updates.relatedEntityType : current.relatedEntityType;
|
|
58
|
+
db.prepare(`
|
|
59
|
+
UPDATE secrets
|
|
60
|
+
SET name = ?, description = ?, category = ?, related_entity_id = ?, related_entity_type = ?
|
|
61
|
+
WHERE id = ?
|
|
62
|
+
`).run(newName, newDescription, newCategory, newRelatedId, newRelatedType, id);
|
|
63
|
+
return {
|
|
64
|
+
...current,
|
|
65
|
+
name: newName,
|
|
66
|
+
description: newDescription,
|
|
67
|
+
category: newCategory,
|
|
68
|
+
relatedEntityId: newRelatedId,
|
|
69
|
+
relatedEntityType: newRelatedType,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function deleteSecret(id) {
|
|
73
|
+
const db = getDatabase();
|
|
74
|
+
const result = db.prepare(`DELETE FROM secrets WHERE id = ?`).run(id);
|
|
75
|
+
return result.changes > 0;
|
|
76
|
+
}
|
|
77
|
+
export function listSecrets(gameId, filter) {
|
|
78
|
+
const db = getDatabase();
|
|
79
|
+
let query = `SELECT * FROM secrets WHERE game_id = ?`;
|
|
80
|
+
const params = [gameId];
|
|
81
|
+
if (filter?.category) {
|
|
82
|
+
query += ` AND category = ?`;
|
|
83
|
+
params.push(filter.category);
|
|
84
|
+
}
|
|
85
|
+
if (filter?.relatedEntityId) {
|
|
86
|
+
query += ` AND related_entity_id = ?`;
|
|
87
|
+
params.push(filter.relatedEntityId);
|
|
88
|
+
}
|
|
89
|
+
if (filter?.isPublic !== undefined) {
|
|
90
|
+
query += ` AND is_public = ?`;
|
|
91
|
+
params.push(filter.isPublic ? 1 : 0);
|
|
92
|
+
}
|
|
93
|
+
query += ` ORDER BY name`;
|
|
94
|
+
const rows = db.prepare(query).all(...params);
|
|
95
|
+
let secrets = rows.map(row => ({
|
|
96
|
+
id: row.id,
|
|
97
|
+
gameId: row.game_id,
|
|
98
|
+
name: row.name,
|
|
99
|
+
description: row.description,
|
|
100
|
+
category: row.category,
|
|
101
|
+
relatedEntityId: row.related_entity_id,
|
|
102
|
+
relatedEntityType: row.related_entity_type,
|
|
103
|
+
revealedTo: safeJsonParse(row.revealed_to || "[]", []),
|
|
104
|
+
isPublic: row.is_public === 1,
|
|
105
|
+
clues: safeJsonParse(row.clues || "[]", []),
|
|
106
|
+
createdAt: row.created_at,
|
|
107
|
+
}));
|
|
108
|
+
// Filter by knownBy if specified
|
|
109
|
+
if (filter?.knownBy) {
|
|
110
|
+
const characterId = filter.knownBy;
|
|
111
|
+
secrets = secrets.filter(s => s.isPublic || s.revealedTo.includes(characterId));
|
|
112
|
+
}
|
|
113
|
+
return secrets;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Modify secret visibility - reveal to specific characters or make public in a single call.
|
|
117
|
+
*/
|
|
118
|
+
export function modifySecretVisibility(secretId, params) {
|
|
119
|
+
const db = getDatabase();
|
|
120
|
+
const secret = getSecret(secretId);
|
|
121
|
+
if (!secret)
|
|
122
|
+
return null;
|
|
123
|
+
const revealedTo = [...secret.revealedTo];
|
|
124
|
+
const revealedToNew = [];
|
|
125
|
+
let madePublic = false;
|
|
126
|
+
let isPublic = secret.isPublic;
|
|
127
|
+
// Reveal to specific characters
|
|
128
|
+
if (params.revealTo) {
|
|
129
|
+
for (const charId of params.revealTo) {
|
|
130
|
+
if (!revealedTo.includes(charId)) {
|
|
131
|
+
revealedTo.push(charId);
|
|
132
|
+
revealedToNew.push(charId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
db.prepare(`UPDATE secrets SET revealed_to = ? WHERE id = ?`)
|
|
136
|
+
.run(JSON.stringify(revealedTo), secretId);
|
|
137
|
+
}
|
|
138
|
+
// Make public
|
|
139
|
+
if (params.makePublic && !secret.isPublic) {
|
|
140
|
+
db.prepare(`UPDATE secrets SET is_public = 1 WHERE id = ?`).run(secretId);
|
|
141
|
+
isPublic = true;
|
|
142
|
+
madePublic = true;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
secret: { ...secret, revealedTo, isPublic },
|
|
146
|
+
revealedToNew,
|
|
147
|
+
madePublic,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function addClue(secretId, clue) {
|
|
151
|
+
const db = getDatabase();
|
|
152
|
+
const secret = getSecret(secretId);
|
|
153
|
+
if (!secret)
|
|
154
|
+
return null;
|
|
155
|
+
const newClues = [...secret.clues, clue];
|
|
156
|
+
db.prepare(`UPDATE secrets SET clues = ? WHERE id = ?`)
|
|
157
|
+
.run(JSON.stringify(newClues), secretId);
|
|
158
|
+
return { ...secret, clues: newClues };
|
|
159
|
+
}
|
|
160
|
+
export function removeClue(secretId, index) {
|
|
161
|
+
const db = getDatabase();
|
|
162
|
+
const secret = getSecret(secretId);
|
|
163
|
+
if (!secret)
|
|
164
|
+
return null;
|
|
165
|
+
if (index < 0 || index >= secret.clues.length) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const newClues = secret.clues.filter((_, i) => i !== index);
|
|
169
|
+
db.prepare(`UPDATE secrets SET clues = ? WHERE id = ?`)
|
|
170
|
+
.run(JSON.stringify(newClues), secretId);
|
|
171
|
+
return { ...secret, clues: newClues };
|
|
172
|
+
}
|
|
173
|
+
export function getCharacterKnowledge(gameId, characterId) {
|
|
174
|
+
const allSecrets = listSecrets(gameId);
|
|
175
|
+
const knownSecrets = allSecrets.filter(s => s.isPublic || s.revealedTo.includes(characterId));
|
|
176
|
+
// For now, clues are available to everyone who knows the secret
|
|
177
|
+
// Could be extended to track which specific clues each character has found
|
|
178
|
+
const cluesFound = [];
|
|
179
|
+
for (const secret of knownSecrets) {
|
|
180
|
+
for (let i = 0; i < secret.clues.length; i++) {
|
|
181
|
+
cluesFound.push({ secret, clueIndex: i, clue: secret.clues[i] });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
characterId,
|
|
186
|
+
knownSecrets,
|
|
187
|
+
cluesFound,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function checkKnowsSecret(secretId, characterId) {
|
|
191
|
+
const secret = getSecret(secretId);
|
|
192
|
+
if (!secret)
|
|
193
|
+
return false;
|
|
194
|
+
return secret.isPublic || secret.revealedTo.includes(characterId);
|
|
195
|
+
}
|