@ghostpaw/questlog 0.9.2 → 0.11.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/README.md +144 -4
- package/SKILL.md +95 -40
- package/dist/chunk-7KTRNXTU.js +21 -0
- package/dist/chunk-D6EGZCT7.js +23 -0
- package/dist/chunk-GVD6A3RR.js +108 -0
- package/dist/{chunk-6KTQK3ZE.js → chunk-I2AMIXGP.js} +8 -0
- package/dist/{chunk-3B6FMHZU.js → chunk-IA2GVCZS.js} +35 -2
- package/dist/{chunk-DKH623HU.js → chunk-IL2QRKZL.js} +1 -4
- package/dist/{chunk-UTUQPSGO.js → chunk-KOWIVBG6.js} +68 -588
- package/dist/chunk-NBCCA6AX.js +31 -0
- package/dist/chunk-QK3H2AL5.js +36 -0
- package/dist/chunk-RBGQK5WP.js +949 -0
- package/dist/cli.js +1 -1
- package/dist/{cli_main-XNZP4TY2.js → cli_main-TEG3QTDP.js} +147 -128
- package/dist/index.cjs +1152 -627
- package/dist/index.d.cts +142 -11
- package/dist/index.d.ts +142 -11
- package/dist/index.js +1152 -628
- package/dist/list_deliveries-WJEJUZIQ.js +2 -0
- package/dist/list_hooks-C5CAA2C7.js +2 -0
- package/dist/mcp-server.js +840 -389
- package/dist/mcp.cjs +709 -322
- package/dist/mcp.js +711 -324
- package/dist/open_questlog_db-6NS6BVBC.js +2 -0
- package/dist/{questlog_skill_spec-EM6DGGVN.js → questlog_skill_spec-ILTCTRYS.js} +16 -8
- package/dist/run_hook_command-BOENYNDT.js +155 -0
- package/dist/{serve_questlog_mcp-KFUYJB2P.js → serve_questlog_mcp-X524X34F.js} +6 -3
- package/dist/{skill_markdown-HXYQ3K6U.js → skill_markdown-EGKYZ3W2.js} +4 -20
- package/dist/text_output-X7F3K23Q.js +2 -0
- package/package.json +1 -1
- package/skills/questlog/SKILL.md +95 -40
- package/skills/questlog/references/webhooks.md +94 -0
- package/dist/open_questlog_db-SH3DAU4C.js +0 -2
package/dist/index.cjs
CHANGED
|
@@ -1,11 +1,1045 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var node_crypto = require('node:crypto');
|
|
4
|
+
var node_path = require('node:path');
|
|
5
|
+
|
|
3
6
|
var __defProp = Object.defineProperty;
|
|
4
7
|
var __export = (target, all) => {
|
|
5
8
|
for (var name in all)
|
|
6
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
// src/hooks/index.ts
|
|
13
|
+
var hooks_exports = {};
|
|
14
|
+
__export(hooks_exports, {
|
|
15
|
+
addHook: () => addHook,
|
|
16
|
+
hookEventTypes: () => hookEventTypes,
|
|
17
|
+
hookLabel: () => hookLabel,
|
|
18
|
+
listDeliveries: () => listDeliveries,
|
|
19
|
+
listHooks: () => listHooks,
|
|
20
|
+
removeHook: () => removeHook,
|
|
21
|
+
testHook: () => testHook,
|
|
22
|
+
tickQuestlog: () => tickQuestlog,
|
|
23
|
+
watchQuestlog: () => watchQuestlog
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// src/tags/normalize_tag_name.ts
|
|
27
|
+
function normalizeTagName(name) {
|
|
28
|
+
return name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/hooks/types.ts
|
|
32
|
+
var hookEventTypes = [
|
|
33
|
+
"quest.due",
|
|
34
|
+
"quest.overdue",
|
|
35
|
+
"quest.unblocked",
|
|
36
|
+
"repeatable.spawned",
|
|
37
|
+
"rumor.captured"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// src/hooks/add_hook.ts
|
|
41
|
+
function addHook(db, input) {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(input.url);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("Expected an HTTP or HTTPS URL");
|
|
47
|
+
}
|
|
48
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
49
|
+
throw new Error(`unsupported scheme ${url.protocol}; use http or https`);
|
|
50
|
+
if (url.username || url.password)
|
|
51
|
+
throw new Error(
|
|
52
|
+
"URL userinfo is not supported; use a custom header (--header or --header-stdin) or a query key instead"
|
|
53
|
+
);
|
|
54
|
+
const minGap = input.minGap ?? 60;
|
|
55
|
+
if (!Number.isSafeInteger(minGap) || minGap < 0)
|
|
56
|
+
throw new Error("Min gap must be a nonnegative integer");
|
|
57
|
+
const events = input.events ?? [...hookEventTypes];
|
|
58
|
+
if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
|
|
59
|
+
throw new Error(
|
|
60
|
+
`unknown event ${events.find((e) => !hookEventTypes.includes(e)) ?? "(empty)"}; valid: ${hookEventTypes.join(", ")}`
|
|
61
|
+
);
|
|
62
|
+
if (input.tag !== void 0 && !normalizeTagName(input.tag))
|
|
63
|
+
throw new Error("Expected a tag name");
|
|
64
|
+
const secret = input.secret ?? node_crypto.randomBytes(32).toString("hex");
|
|
65
|
+
if (!secret.trim()) throw new Error("Expected a nonempty signing secret");
|
|
66
|
+
const result = db.prepare(
|
|
67
|
+
"INSERT INTO hook_targets(url, secret, tag, events, min_gap, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
68
|
+
).run(
|
|
69
|
+
url.href,
|
|
70
|
+
secret,
|
|
71
|
+
input.tag === void 0 ? null : normalizeTagName(input.tag),
|
|
72
|
+
JSON.stringify(events),
|
|
73
|
+
minGap,
|
|
74
|
+
input.now ?? Date.now()
|
|
75
|
+
);
|
|
76
|
+
return { id: Number(result.lastInsertRowid), secret };
|
|
77
|
+
}
|
|
78
|
+
function hookLabel(db, name) {
|
|
79
|
+
if (name !== void 0) {
|
|
80
|
+
if (!name.trim()) throw new Error("Expected a nonempty database label");
|
|
81
|
+
db.prepare(
|
|
82
|
+
"INSERT INTO hook_settings(id, label) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET label = excluded.label"
|
|
83
|
+
).run(name);
|
|
84
|
+
}
|
|
85
|
+
const saved = db.prepare("SELECT label FROM hook_settings WHERE id = 1").get();
|
|
86
|
+
if (saved) return String(saved.label);
|
|
87
|
+
const path = String(
|
|
88
|
+
db.prepare("PRAGMA database_list").all().find((row) => row.name === "main")?.file ?? ""
|
|
89
|
+
);
|
|
90
|
+
return path ? node_path.basename(path, node_path.extname(path)) : "questlog";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/hooks/list_deliveries.ts
|
|
94
|
+
function listDeliveries(db, options = {}) {
|
|
95
|
+
return db.prepare(
|
|
96
|
+
`SELECT * FROM hook_deliveries ${options.includeDelivered ? "" : "WHERE status <> 'delivered'"} ORDER BY target_id, next_attempt_at, id`
|
|
97
|
+
).all().map(
|
|
98
|
+
(row) => ({
|
|
99
|
+
id: String(row.id),
|
|
100
|
+
targetId: Number(row.target_id),
|
|
101
|
+
events: JSON.parse(String(row.events)),
|
|
102
|
+
status: row.status,
|
|
103
|
+
attempts: Number(row.attempts),
|
|
104
|
+
lastAttemptAt: row.last_attempt_at == null ? null : Number(row.last_attempt_at),
|
|
105
|
+
httpStatus: row.http_status == null ? null : Number(row.http_status),
|
|
106
|
+
errorClass: row.error_class == null ? null : String(row.error_class),
|
|
107
|
+
nextAttemptAt: Number(row.next_attempt_at)
|
|
108
|
+
})
|
|
109
|
+
).filter(
|
|
110
|
+
(d) => options.questId === void 0 || d.events.some((e) => e.questId === options.questId)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/hooks/list_hooks.ts
|
|
115
|
+
function listHooks(db) {
|
|
116
|
+
return db.prepare(`SELECT id, url, headers, tag, events, min_gap, created_at, last_wake,
|
|
117
|
+
(SELECT COUNT(*) FROM hook_assignments a LEFT JOIN hook_deliveries d ON d.id = a.delivery_id WHERE a.target_id = t.id AND (a.delivery_id IS NULL OR d.status = 'pending')) AS pending,
|
|
118
|
+
(SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
|
|
119
|
+
FROM hook_targets t ORDER BY id`).all().map((row) => {
|
|
120
|
+
const url = new URL(String(row.url));
|
|
121
|
+
url.username = "";
|
|
122
|
+
url.password = "";
|
|
123
|
+
url.search = "";
|
|
124
|
+
url.hash = "";
|
|
125
|
+
return {
|
|
126
|
+
id: Number(row.id),
|
|
127
|
+
headers: Object.fromEntries(
|
|
128
|
+
Object.keys(JSON.parse(String(row.headers))).map((name) => [name, "***"])
|
|
129
|
+
),
|
|
130
|
+
scope: row.tag == null ? "database" : "tag",
|
|
131
|
+
url: url.href,
|
|
132
|
+
tag: row.tag == null ? null : String(row.tag),
|
|
133
|
+
events: JSON.parse(String(row.events)),
|
|
134
|
+
minGap: Number(row.min_gap),
|
|
135
|
+
createdAt: Number(row.created_at),
|
|
136
|
+
lastWake: row.last_wake == null ? null : Number(row.last_wake),
|
|
137
|
+
pending: Number(row.pending),
|
|
138
|
+
failed: Number(row.failed)
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/with_transaction.ts
|
|
144
|
+
var transactionDepth = /* @__PURE__ */ new WeakMap();
|
|
145
|
+
function withTransaction(db, fn) {
|
|
146
|
+
const depth = transactionDepth.get(db) ?? 0;
|
|
147
|
+
const savepoint = `questlog_${depth}`;
|
|
148
|
+
if (depth === 0) {
|
|
149
|
+
db.exec("BEGIN IMMEDIATE");
|
|
150
|
+
} else {
|
|
151
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
152
|
+
}
|
|
153
|
+
transactionDepth.set(db, depth + 1);
|
|
154
|
+
try {
|
|
155
|
+
const result = fn();
|
|
156
|
+
if (depth === 0) {
|
|
157
|
+
db.exec("COMMIT");
|
|
158
|
+
} else {
|
|
159
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
160
|
+
}
|
|
161
|
+
transactionDepth.set(db, depth);
|
|
162
|
+
return result;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
try {
|
|
165
|
+
if (depth === 0) {
|
|
166
|
+
db.exec("ROLLBACK");
|
|
167
|
+
} else {
|
|
168
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
169
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
transactionDepth.set(db, depth);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/hooks/remove_hook.ts
|
|
179
|
+
function removeHook(db, id) {
|
|
180
|
+
withTransaction(db, () => {
|
|
181
|
+
db.prepare("DELETE FROM hook_assignments WHERE target_id = ?").run(id);
|
|
182
|
+
db.prepare("DELETE FROM hook_deliveries WHERE target_id = ?").run(id);
|
|
183
|
+
db.prepare("DELETE FROM hook_targets WHERE id = ?").run(id);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/version.ts
|
|
188
|
+
var questlogVersion = "0.11.0";
|
|
189
|
+
|
|
190
|
+
// src/hooks/send_hook_delivery.ts
|
|
191
|
+
async function sendHookDelivery(db, delivery, target, options) {
|
|
192
|
+
const now = options.now ?? options.clock?.() ?? Date.now();
|
|
193
|
+
const timestamp = String(Math.floor(now / 1e3));
|
|
194
|
+
const body = JSON.stringify({
|
|
195
|
+
type: "questlog.events",
|
|
196
|
+
db: hookLabel(db),
|
|
197
|
+
deliveryId: delivery.id,
|
|
198
|
+
sentAt: new Date(now).toISOString(),
|
|
199
|
+
events: delivery.events
|
|
200
|
+
});
|
|
201
|
+
const signature = node_crypto.createHmac("sha256", target.secret).update(`${timestamp}.${body}`).digest("hex");
|
|
202
|
+
let status = null;
|
|
203
|
+
let errorClass = null;
|
|
204
|
+
try {
|
|
205
|
+
const response = await (options.fetch ?? globalThis.fetch)(target.url, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
redirect: "error",
|
|
208
|
+
headers: {
|
|
209
|
+
"user-agent": `questlog/${questlogVersion}`,
|
|
210
|
+
...JSON.parse(
|
|
211
|
+
String(
|
|
212
|
+
db.prepare("SELECT headers FROM hook_targets WHERE id = ?").get(target.id)?.headers ?? "{}"
|
|
213
|
+
)
|
|
214
|
+
),
|
|
215
|
+
"content-type": "application/json",
|
|
216
|
+
"x-questlog-delivery": delivery.id,
|
|
217
|
+
"x-questlog-timestamp": timestamp,
|
|
218
|
+
"x-questlog-signature": `sha256=${signature}`
|
|
219
|
+
},
|
|
220
|
+
body,
|
|
221
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(options.timeoutMs ?? 5e3, 5e3)))
|
|
222
|
+
});
|
|
223
|
+
status = response.status;
|
|
224
|
+
await response.body?.cancel();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
errorClass = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name) ? error.name : "NetworkError";
|
|
227
|
+
}
|
|
228
|
+
const delivered = status != null && status >= 200 && status < 300;
|
|
229
|
+
const completedAt = options.now ?? options.clock?.() ?? Date.now();
|
|
230
|
+
withTransaction(db, () => {
|
|
231
|
+
if (!db.prepare("SELECT id FROM hook_targets WHERE id = ? AND claim = ?").get(target.id, target.claim))
|
|
232
|
+
return;
|
|
233
|
+
const delay = Math.min(36e5, 3e4 * 2 ** (delivery.attempts - 1));
|
|
234
|
+
const jitter = Number.parseInt(node_crypto.randomBytes(2).toString("hex"), 16) % 1e3;
|
|
235
|
+
db.prepare(
|
|
236
|
+
"UPDATE hook_deliveries SET status = ?, http_status = ?, error_class = ?, next_attempt_at = ? WHERE id = ?"
|
|
237
|
+
).run(
|
|
238
|
+
delivered ? "delivered" : delivery.attempts >= 8 ? "failed" : "pending",
|
|
239
|
+
status,
|
|
240
|
+
errorClass,
|
|
241
|
+
completedAt + delay + jitter,
|
|
242
|
+
delivery.id
|
|
243
|
+
);
|
|
244
|
+
db.prepare(
|
|
245
|
+
"UPDATE hook_targets SET claim = NULL, lease_until = NULL WHERE id = ? AND claim = ?"
|
|
246
|
+
).run(target.id, target.claim);
|
|
247
|
+
db.prepare(
|
|
248
|
+
"DELETE FROM hook_deliveries WHERE target_id = ? AND status = 'delivered' AND id NOT IN (SELECT id FROM hook_deliveries WHERE target_id = ? AND status = 'delivered' ORDER BY last_attempt_at DESC, id DESC LIMIT 500)"
|
|
249
|
+
).run(target.id, target.id);
|
|
250
|
+
});
|
|
251
|
+
return delivered;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/hooks/test_hook.ts
|
|
255
|
+
async function testHook(db, id, options = {}) {
|
|
256
|
+
const now = options.now ?? options.clock?.() ?? Date.now();
|
|
257
|
+
const claim = node_crypto.randomBytes(16).toString("hex");
|
|
258
|
+
const delivery = {
|
|
259
|
+
id: `d_${node_crypto.randomBytes(16).toString("hex")}`,
|
|
260
|
+
targetId: id,
|
|
261
|
+
events: [
|
|
262
|
+
{
|
|
263
|
+
id: `e_test_${node_crypto.randomBytes(16).toString("hex")}`,
|
|
264
|
+
type: "hook.test",
|
|
265
|
+
occurredAt: new Date(now).toISOString()
|
|
266
|
+
}
|
|
267
|
+
],
|
|
268
|
+
status: "pending",
|
|
269
|
+
attempts: 1,
|
|
270
|
+
lastAttemptAt: now,
|
|
271
|
+
httpStatus: null,
|
|
272
|
+
errorClass: null,
|
|
273
|
+
nextAttemptAt: now
|
|
274
|
+
};
|
|
275
|
+
const target = withTransaction(db, () => {
|
|
276
|
+
const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(id);
|
|
277
|
+
if (!row) throw new Error("Hook not found");
|
|
278
|
+
if (row.lease_until != null && Number(row.lease_until) > now)
|
|
279
|
+
throw new Error("Hook delivery is in progress");
|
|
280
|
+
db.prepare(
|
|
281
|
+
"INSERT INTO hook_deliveries(id, target_id, events, status, attempts, last_attempt_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
282
|
+
).run(delivery.id, id, JSON.stringify(delivery.events), "pending", 1, now, now);
|
|
283
|
+
db.prepare(
|
|
284
|
+
"UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
|
|
285
|
+
).run(claim, now + 12e4, now, id);
|
|
286
|
+
return { id, url: String(row.url), secret: String(row.secret), claim };
|
|
287
|
+
});
|
|
288
|
+
return { id: delivery.id, delivered: await sendHookDelivery(db, delivery, target, options) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/quests/map_quest_row.ts
|
|
292
|
+
function asBoolean(value) {
|
|
293
|
+
return Number(value) === 1;
|
|
294
|
+
}
|
|
295
|
+
function mapQuestRow(row) {
|
|
296
|
+
return {
|
|
297
|
+
id: Number(row.id),
|
|
298
|
+
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
299
|
+
sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
|
|
300
|
+
spawnedByQuestId: row.spawned_by_quest_id == null ? null : Number(row.spawned_by_quest_id),
|
|
301
|
+
spawnedFromRepeatableId: row.spawned_from_repeatable_id == null ? null : Number(row.spawned_from_repeatable_id),
|
|
302
|
+
spawnedForAt: row.spawned_for_at == null ? null : Number(row.spawned_for_at),
|
|
303
|
+
title: String(row.title),
|
|
304
|
+
objective: String(row.objective),
|
|
305
|
+
outcome: row.outcome == null ? null : String(row.outcome),
|
|
306
|
+
createdAt: Number(row.created_at),
|
|
307
|
+
updatedAt: Number(row.updated_at),
|
|
308
|
+
startedAt: row.started_at == null ? null : Number(row.started_at),
|
|
309
|
+
resolvedAt: row.resolved_at == null ? null : Number(row.resolved_at),
|
|
310
|
+
success: row.success == null ? null : asBoolean(row.success),
|
|
311
|
+
effortSeconds: Number(row.effort_seconds),
|
|
312
|
+
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
313
|
+
notBeforeAt: row.not_before_at == null ? null : Number(row.not_before_at),
|
|
314
|
+
dueAt: row.due_at == null ? null : Number(row.due_at),
|
|
315
|
+
scheduledStartAt: row.scheduled_start_at == null ? null : Number(row.scheduled_start_at),
|
|
316
|
+
scheduledEndAt: row.scheduled_end_at == null ? null : Number(row.scheduled_end_at),
|
|
317
|
+
allDay: asBoolean(row.all_day),
|
|
318
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/quests/get_quest_or_throw.ts
|
|
323
|
+
function getQuestOrThrow(db, questId) {
|
|
324
|
+
const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
|
|
325
|
+
if (!row) {
|
|
326
|
+
throw new Error(`Quest ${questId} was not found.`);
|
|
327
|
+
}
|
|
328
|
+
return mapQuestRow(row);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/resolve_now.ts
|
|
332
|
+
function resolveNow(now) {
|
|
333
|
+
return now ?? Date.now();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/rewards/copy_repeatable_quest_rewards_to_quest.ts
|
|
337
|
+
function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
|
|
338
|
+
const effectiveNow = resolveNow(now);
|
|
339
|
+
const rows = db.prepare(
|
|
340
|
+
`SELECT kind, name, description, quantity
|
|
341
|
+
FROM repeatable_quest_rewards
|
|
342
|
+
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
343
|
+
).all(repeatableQuestId);
|
|
344
|
+
for (const row of rows) {
|
|
345
|
+
db.prepare(
|
|
346
|
+
`INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
348
|
+
).run(
|
|
349
|
+
questId,
|
|
350
|
+
String(row.kind),
|
|
351
|
+
String(row.name),
|
|
352
|
+
row.description == null ? null : String(row.description),
|
|
353
|
+
row.quantity == null ? null : Number(row.quantity),
|
|
354
|
+
effectiveNow
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/tags/copy_repeatable_quest_tags_to_quest.ts
|
|
360
|
+
function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
|
|
361
|
+
const effectiveNow = resolveNow(now);
|
|
362
|
+
const rows = db.prepare(
|
|
363
|
+
`SELECT tag_id
|
|
364
|
+
FROM repeatable_quest_tags
|
|
365
|
+
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
366
|
+
).all(repeatableQuestId);
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
db.prepare(
|
|
369
|
+
`INSERT INTO quest_tags (quest_id, tag_id, created_at)
|
|
370
|
+
VALUES (?, ?, ?)`
|
|
371
|
+
).run(questId, Number(row.tag_id), effectiveNow);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/repeatable_quests/map_repeatable_quest_row.ts
|
|
376
|
+
function asBoolean2(value) {
|
|
377
|
+
return Number(value) === 1;
|
|
378
|
+
}
|
|
379
|
+
function mapRepeatableQuestRow(row) {
|
|
380
|
+
return {
|
|
381
|
+
id: Number(row.id),
|
|
382
|
+
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
383
|
+
title: String(row.title),
|
|
384
|
+
objective: String(row.objective),
|
|
385
|
+
rrule: String(row.rrule),
|
|
386
|
+
anchorAt: Number(row.anchor_at),
|
|
387
|
+
notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
|
|
388
|
+
dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
|
|
389
|
+
scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
|
|
390
|
+
scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
|
|
391
|
+
allDay: asBoolean2(row.all_day),
|
|
392
|
+
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
393
|
+
createdAt: Number(row.created_at),
|
|
394
|
+
updatedAt: Number(row.updated_at),
|
|
395
|
+
archivedAt: row.archived_at == null ? null : Number(row.archived_at),
|
|
396
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/repeatable_quests/get_repeatable_quest_or_throw.ts
|
|
401
|
+
function getRepeatableQuestOrThrow(db, repeatableQuestId) {
|
|
402
|
+
const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
|
|
403
|
+
if (!row) {
|
|
404
|
+
throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
|
|
405
|
+
}
|
|
406
|
+
return mapRepeatableQuestRow(row);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/repeatable_quests/advance_anchor.ts
|
|
410
|
+
var WEEK_MS = 7 * 864e5;
|
|
411
|
+
function advanceAnchor(anchorAt, freq, interval) {
|
|
412
|
+
const date = new Date(anchorAt);
|
|
413
|
+
if (freq === "MONTHLY") {
|
|
414
|
+
date.setUTCMonth(date.getUTCMonth() + interval);
|
|
415
|
+
return date.getTime();
|
|
416
|
+
}
|
|
417
|
+
if (freq === "YEARLY") {
|
|
418
|
+
date.setUTCFullYear(date.getUTCFullYear() + interval);
|
|
419
|
+
return date.getTime();
|
|
420
|
+
}
|
|
421
|
+
return anchorAt + interval * WEEK_MS;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/repeatable_quests/parse_rrule.ts
|
|
425
|
+
var WEEKDAY = {
|
|
426
|
+
SU: 0,
|
|
427
|
+
MO: 1,
|
|
428
|
+
TU: 2,
|
|
429
|
+
WE: 3,
|
|
430
|
+
TH: 4,
|
|
431
|
+
FR: 5,
|
|
432
|
+
SA: 6
|
|
433
|
+
};
|
|
434
|
+
function parseRRule(rrule) {
|
|
435
|
+
const parts = Object.fromEntries(
|
|
436
|
+
rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
437
|
+
const [key, value] = part.split("=");
|
|
438
|
+
return [key, value];
|
|
439
|
+
})
|
|
440
|
+
);
|
|
441
|
+
const freq = parts.FREQ;
|
|
442
|
+
if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
|
|
443
|
+
throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
|
|
444
|
+
}
|
|
445
|
+
const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
|
|
446
|
+
if (!Number.isFinite(interval) || interval < 1) {
|
|
447
|
+
throw new Error("RRULE INTERVAL must be a positive integer.");
|
|
448
|
+
}
|
|
449
|
+
const count = parts.COUNT == null ? null : Number(parts.COUNT);
|
|
450
|
+
if (count != null && (!Number.isFinite(count) || count < 1)) {
|
|
451
|
+
throw new Error("RRULE COUNT must be a positive integer.");
|
|
452
|
+
}
|
|
453
|
+
const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
|
|
454
|
+
if (parts.UNTIL != null && Number.isNaN(until)) {
|
|
455
|
+
throw new Error("RRULE UNTIL must be parseable as a date.");
|
|
456
|
+
}
|
|
457
|
+
const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
|
|
458
|
+
const value = WEEKDAY[token];
|
|
459
|
+
if (value == null) {
|
|
460
|
+
throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
|
|
461
|
+
}
|
|
462
|
+
return value;
|
|
463
|
+
});
|
|
464
|
+
return { freq, interval, count, until, byDay };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/repeatable_quests/start_of_utc_day.ts
|
|
468
|
+
function startOfUtcDay(timestamp) {
|
|
469
|
+
const date = new Date(timestamp);
|
|
470
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/repeatable_quests/start_of_utc_week.ts
|
|
474
|
+
var DAY_MS = 864e5;
|
|
475
|
+
function startOfUtcWeek(dayStart) {
|
|
476
|
+
const date = new Date(dayStart);
|
|
477
|
+
return dayStart - date.getUTCDay() * DAY_MS;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/repeatable_quests/generate_anchors_until.ts
|
|
481
|
+
var DAY_MS2 = 864e5;
|
|
482
|
+
var WEEK_MS2 = 7 * DAY_MS2;
|
|
483
|
+
function generateAnchorsUntil(repeatable, now) {
|
|
484
|
+
const rule = parseRRule(repeatable.rrule);
|
|
485
|
+
const anchors = [];
|
|
486
|
+
const until = rule.until == null ? now : Math.min(now, rule.until);
|
|
487
|
+
if (repeatable.anchorAt > until) {
|
|
488
|
+
return anchors;
|
|
489
|
+
}
|
|
490
|
+
if (rule.freq === "DAILY") {
|
|
491
|
+
let occurrence2 = 0;
|
|
492
|
+
for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
|
|
493
|
+
occurrence2 += 1;
|
|
494
|
+
if (rule.count != null && occurrence2 > rule.count) {
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
anchors.push(current2);
|
|
498
|
+
}
|
|
499
|
+
return anchors;
|
|
500
|
+
}
|
|
501
|
+
if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
|
|
502
|
+
const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
|
|
503
|
+
const anchorWeekStart = startOfUtcWeek(anchorDayStart);
|
|
504
|
+
const timeOffset = repeatable.anchorAt - anchorDayStart;
|
|
505
|
+
let occurrence2 = 0;
|
|
506
|
+
for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
|
|
507
|
+
const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
|
|
508
|
+
if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (!rule.byDay.includes(new Date(day).getUTCDay())) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const anchor = day + timeOffset;
|
|
515
|
+
if (anchor < repeatable.anchorAt || anchor > until) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
occurrence2 += 1;
|
|
519
|
+
if (rule.count != null && occurrence2 > rule.count) {
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
anchors.push(anchor);
|
|
523
|
+
}
|
|
524
|
+
return anchors;
|
|
525
|
+
}
|
|
526
|
+
let occurrence = 0;
|
|
527
|
+
let current = repeatable.anchorAt;
|
|
528
|
+
while (current <= until) {
|
|
529
|
+
occurrence += 1;
|
|
530
|
+
if (rule.count != null && occurrence > rule.count) {
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
anchors.push(current);
|
|
534
|
+
current = advanceAnchor(current, rule.freq, rule.interval);
|
|
535
|
+
}
|
|
536
|
+
return anchors;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/repeatable_quests/list_due_repeatable_quest_anchors.ts
|
|
540
|
+
function listDueRepeatableQuestAnchors(db, now) {
|
|
541
|
+
const repeatables = db.prepare(
|
|
542
|
+
`SELECT *
|
|
543
|
+
FROM repeatable_quests
|
|
544
|
+
WHERE deleted_at IS NULL
|
|
545
|
+
AND archived_at IS NULL`
|
|
546
|
+
).all().map(mapRepeatableQuestRow);
|
|
547
|
+
const anchors = [];
|
|
548
|
+
if (repeatables.length === 0) {
|
|
549
|
+
return anchors;
|
|
550
|
+
}
|
|
551
|
+
const repeatableIds = repeatables.map((repeatable) => repeatable.id);
|
|
552
|
+
const maxAnchorAt = Math.max(
|
|
553
|
+
...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
|
|
554
|
+
);
|
|
555
|
+
const existingRows = db.prepare(
|
|
556
|
+
`SELECT spawned_from_repeatable_id, spawned_for_at
|
|
557
|
+
FROM quests
|
|
558
|
+
WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
|
|
559
|
+
AND spawned_for_at IS NOT NULL
|
|
560
|
+
AND spawned_for_at <= ?`
|
|
561
|
+
).all(...repeatableIds, maxAnchorAt);
|
|
562
|
+
const existingAnchors = new Set(
|
|
563
|
+
existingRows.map(
|
|
564
|
+
(row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
for (const repeatable of repeatables) {
|
|
568
|
+
const dueAnchors = generateAnchorsUntil(repeatable, now);
|
|
569
|
+
for (const anchorAt of dueAnchors) {
|
|
570
|
+
if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
|
|
571
|
+
anchors.push({
|
|
572
|
+
repeatableQuestId: repeatable.id,
|
|
573
|
+
anchorAt,
|
|
574
|
+
markerId: "attention.available.repeatable"
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return anchors.sort(
|
|
580
|
+
(a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/repeatable_quests/spawn_due_repeatable_quests.ts
|
|
585
|
+
function spawnDueRepeatableQuests(db, now) {
|
|
586
|
+
return withTransaction(db, () => {
|
|
587
|
+
const anchors = listDueRepeatableQuestAnchors(db, now);
|
|
588
|
+
const quests = [];
|
|
589
|
+
for (const anchor of anchors) {
|
|
590
|
+
const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
|
|
591
|
+
const created = db.prepare(
|
|
592
|
+
`INSERT INTO quests (
|
|
593
|
+
questline_id,
|
|
594
|
+
source_rumor_id,
|
|
595
|
+
spawned_by_quest_id,
|
|
596
|
+
spawned_from_repeatable_id,
|
|
597
|
+
spawned_for_at,
|
|
598
|
+
title,
|
|
599
|
+
objective,
|
|
600
|
+
created_at,
|
|
601
|
+
updated_at,
|
|
602
|
+
effort_seconds,
|
|
603
|
+
estimate_seconds,
|
|
604
|
+
not_before_at,
|
|
605
|
+
due_at,
|
|
606
|
+
scheduled_start_at,
|
|
607
|
+
scheduled_end_at,
|
|
608
|
+
all_day
|
|
609
|
+
) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
|
|
610
|
+
).run(
|
|
611
|
+
repeatable.questlineId ?? null,
|
|
612
|
+
repeatable.id,
|
|
613
|
+
anchor.anchorAt,
|
|
614
|
+
repeatable.title,
|
|
615
|
+
repeatable.objective,
|
|
616
|
+
now,
|
|
617
|
+
now,
|
|
618
|
+
repeatable.estimateSeconds ?? null,
|
|
619
|
+
repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
|
|
620
|
+
repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
|
|
621
|
+
repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
|
|
622
|
+
repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
|
|
623
|
+
repeatable.allDay ? 1 : 0
|
|
624
|
+
);
|
|
625
|
+
const questId = Number(created.lastInsertRowid);
|
|
626
|
+
copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
|
|
627
|
+
copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
|
|
628
|
+
quests.push(getQuestOrThrow(db, questId));
|
|
629
|
+
}
|
|
630
|
+
return quests;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/markers/derive_quest_marker_id.ts
|
|
635
|
+
function deriveQuestMarkerId({
|
|
636
|
+
state,
|
|
637
|
+
available,
|
|
638
|
+
hasUnclaimedReward,
|
|
639
|
+
notBeforeAt,
|
|
640
|
+
blockerCount,
|
|
641
|
+
now
|
|
642
|
+
}) {
|
|
643
|
+
if (state === "done") {
|
|
644
|
+
return hasUnclaimedReward ? "progress.complete" : null;
|
|
645
|
+
}
|
|
646
|
+
if (state === "abandoned") {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
if (state === "in_progress") {
|
|
650
|
+
return "progress.incomplete";
|
|
651
|
+
}
|
|
652
|
+
if (available) {
|
|
653
|
+
return "attention.available";
|
|
654
|
+
}
|
|
655
|
+
if (notBeforeAt != null && notBeforeAt > now) {
|
|
656
|
+
return "attention.available.future";
|
|
657
|
+
}
|
|
658
|
+
if (blockerCount > 0) {
|
|
659
|
+
return "progress.incomplete";
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/rewards/map_quest_reward_row.ts
|
|
665
|
+
function mapQuestRewardRow(row) {
|
|
666
|
+
return {
|
|
667
|
+
id: Number(row.id),
|
|
668
|
+
questId: Number(row.quest_id),
|
|
669
|
+
kind: String(row.kind),
|
|
670
|
+
name: String(row.name),
|
|
671
|
+
description: row.description == null ? null : String(row.description),
|
|
672
|
+
quantity: row.quantity == null ? null : Number(row.quantity),
|
|
673
|
+
createdAt: Number(row.created_at),
|
|
674
|
+
claimedAt: row.claimed_at == null ? null : Number(row.claimed_at),
|
|
675
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/quests/derive_quest_state.ts
|
|
680
|
+
function deriveQuestState(quest) {
|
|
681
|
+
if (quest.resolvedAt != null && quest.success === true) {
|
|
682
|
+
return "done";
|
|
683
|
+
}
|
|
684
|
+
if (quest.resolvedAt != null && quest.success === false) {
|
|
685
|
+
return "abandoned";
|
|
686
|
+
}
|
|
687
|
+
if (quest.startedAt != null) {
|
|
688
|
+
return "in_progress";
|
|
689
|
+
}
|
|
690
|
+
return "open";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/quests/query_quest_detail_records.ts
|
|
694
|
+
function queryQuestDetailRecords(db, filters = {}, now = Date.now(), questIds) {
|
|
695
|
+
if (questIds && questIds.length === 0) {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
const clauses = [];
|
|
699
|
+
const params = [];
|
|
700
|
+
if (!filters.includeDeleted) {
|
|
701
|
+
clauses.push("q.deleted_at IS NULL");
|
|
702
|
+
}
|
|
703
|
+
if (questIds && questIds.length > 0) {
|
|
704
|
+
clauses.push(`q.id IN (${questIds.map(() => "?").join(", ")})`);
|
|
705
|
+
params.push(...questIds);
|
|
706
|
+
}
|
|
707
|
+
if (filters.questlineId != null) {
|
|
708
|
+
clauses.push("q.questline_id = ?");
|
|
709
|
+
params.push(filters.questlineId);
|
|
710
|
+
}
|
|
711
|
+
if (filters.tagNames && filters.tagNames.length > 0) {
|
|
712
|
+
const normalized = [...new Set(filters.tagNames.map(normalizeTagName).filter(Boolean))];
|
|
713
|
+
for (const tag of normalized) {
|
|
714
|
+
clauses.push(
|
|
715
|
+
`EXISTS (
|
|
716
|
+
SELECT 1
|
|
717
|
+
FROM quest_tags qt
|
|
718
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
719
|
+
WHERE qt.quest_id = q.id
|
|
720
|
+
AND qt.deleted_at IS NULL
|
|
721
|
+
AND t.deleted_at IS NULL
|
|
722
|
+
AND t.normalized_name = ?
|
|
723
|
+
)`
|
|
724
|
+
);
|
|
725
|
+
params.push(tag);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
729
|
+
const rows = db.prepare(
|
|
730
|
+
`SELECT q.*,
|
|
731
|
+
COALESCE(q.due_at, ql.due_at) AS effective_due_at,
|
|
732
|
+
(
|
|
733
|
+
SELECT COUNT(*)
|
|
734
|
+
FROM quest_unlocks qu
|
|
735
|
+
JOIN quests blocker ON blocker.id = qu.from_quest_id
|
|
736
|
+
WHERE qu.to_quest_id = q.id
|
|
737
|
+
AND qu.deleted_at IS NULL
|
|
738
|
+
AND blocker.deleted_at IS NULL
|
|
739
|
+
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
740
|
+
) AS blocker_count
|
|
741
|
+
FROM quests q
|
|
742
|
+
LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
|
|
743
|
+
${where}
|
|
744
|
+
ORDER BY q.created_at DESC, q.id DESC`
|
|
745
|
+
).all(...params);
|
|
746
|
+
if (rows.length === 0) {
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
const ids = rows.map((row) => Number(row.id));
|
|
750
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
751
|
+
const tagNamesByQuestId = /* @__PURE__ */ new Map();
|
|
752
|
+
const rewardsByQuestId = /* @__PURE__ */ new Map();
|
|
753
|
+
const unlocksByQuestId = /* @__PURE__ */ new Map();
|
|
754
|
+
const unlockedByQuestId = /* @__PURE__ */ new Map();
|
|
755
|
+
for (const row of db.prepare(
|
|
756
|
+
`SELECT qt.quest_id, t.name
|
|
757
|
+
FROM quest_tags qt
|
|
758
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
759
|
+
WHERE qt.quest_id IN (${placeholders})
|
|
760
|
+
AND qt.deleted_at IS NULL
|
|
761
|
+
AND t.deleted_at IS NULL
|
|
762
|
+
ORDER BY qt.quest_id, t.name`
|
|
763
|
+
).all(...ids)) {
|
|
764
|
+
const questId = Number(row.quest_id);
|
|
765
|
+
const tagNames = tagNamesByQuestId.get(questId) ?? [];
|
|
766
|
+
tagNames.push(String(row.name));
|
|
767
|
+
tagNamesByQuestId.set(questId, tagNames);
|
|
768
|
+
}
|
|
769
|
+
for (const row of db.prepare(
|
|
770
|
+
`SELECT *
|
|
771
|
+
FROM quest_rewards
|
|
772
|
+
WHERE quest_id IN (${placeholders})
|
|
773
|
+
AND deleted_at IS NULL
|
|
774
|
+
ORDER BY quest_id, created_at, id`
|
|
775
|
+
).all(...ids)) {
|
|
776
|
+
const reward = mapQuestRewardRow(row);
|
|
777
|
+
const rewards = rewardsByQuestId.get(reward.questId) ?? [];
|
|
778
|
+
rewards.push(reward);
|
|
779
|
+
rewardsByQuestId.set(reward.questId, rewards);
|
|
780
|
+
}
|
|
781
|
+
for (const row of db.prepare(
|
|
782
|
+
`SELECT from_quest_id, to_quest_id
|
|
783
|
+
FROM quest_unlocks
|
|
784
|
+
WHERE from_quest_id IN (${placeholders})
|
|
785
|
+
AND deleted_at IS NULL
|
|
786
|
+
ORDER BY from_quest_id, to_quest_id`
|
|
787
|
+
).all(...ids)) {
|
|
788
|
+
const questId = Number(row.from_quest_id);
|
|
789
|
+
const unlocks = unlocksByQuestId.get(questId) ?? [];
|
|
790
|
+
unlocks.push(Number(row.to_quest_id));
|
|
791
|
+
unlocksByQuestId.set(questId, unlocks);
|
|
792
|
+
}
|
|
793
|
+
for (const row of db.prepare(
|
|
794
|
+
`SELECT from_quest_id, to_quest_id
|
|
795
|
+
FROM quest_unlocks
|
|
796
|
+
WHERE to_quest_id IN (${placeholders})
|
|
797
|
+
AND deleted_at IS NULL
|
|
798
|
+
ORDER BY to_quest_id, from_quest_id`
|
|
799
|
+
).all(...ids)) {
|
|
800
|
+
const questId = Number(row.to_quest_id);
|
|
801
|
+
const unlockedBy = unlockedByQuestId.get(questId) ?? [];
|
|
802
|
+
unlockedBy.push(Number(row.from_quest_id));
|
|
803
|
+
unlockedByQuestId.set(questId, unlockedBy);
|
|
804
|
+
}
|
|
805
|
+
return rows.map((row) => {
|
|
806
|
+
const quest = mapQuestRow(row);
|
|
807
|
+
const blockerCount = Number(row.blocker_count ?? 0);
|
|
808
|
+
const effectiveDueAt = row.effective_due_at == null ? null : Number(row.effective_due_at);
|
|
809
|
+
const rewards = rewardsByQuestId.get(quest.id) ?? [];
|
|
810
|
+
const state = deriveQuestState(quest);
|
|
811
|
+
const available = quest.deletedAt == null && quest.resolvedAt == null && (quest.notBeforeAt == null || quest.notBeforeAt <= now) && blockerCount === 0;
|
|
812
|
+
const markerId = deriveQuestMarkerId({
|
|
813
|
+
state,
|
|
814
|
+
available,
|
|
815
|
+
hasUnclaimedReward: rewards.some((reward) => reward.claimedAt == null),
|
|
816
|
+
notBeforeAt: quest.notBeforeAt,
|
|
817
|
+
blockerCount,
|
|
818
|
+
now
|
|
819
|
+
});
|
|
820
|
+
return {
|
|
821
|
+
detail: {
|
|
822
|
+
...quest,
|
|
823
|
+
state,
|
|
824
|
+
markerId,
|
|
825
|
+
effectiveDueAt,
|
|
826
|
+
available,
|
|
827
|
+
tagNames: tagNamesByQuestId.get(quest.id) ?? [],
|
|
828
|
+
rewards,
|
|
829
|
+
unlocksQuestIds: unlocksByQuestId.get(quest.id) ?? [],
|
|
830
|
+
unlockedByQuestIds: unlockedByQuestId.get(quest.id) ?? []
|
|
831
|
+
},
|
|
832
|
+
blockerCount
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/hooks/detect_hook_events.ts
|
|
838
|
+
function detectHookEvents(db, now) {
|
|
839
|
+
const events = [];
|
|
840
|
+
const add = (type, at, ids) => {
|
|
841
|
+
events.push({
|
|
842
|
+
id: `e_${type}_${ids.questId ?? ids.rumorId}_${ids.repeatableId ?? ""}_${at}`,
|
|
843
|
+
type,
|
|
844
|
+
...ids,
|
|
845
|
+
occurredAt: new Date(at).toISOString()
|
|
846
|
+
});
|
|
847
|
+
};
|
|
848
|
+
for (const { detail: q } of queryQuestDetailRecords(db, {}, now)) {
|
|
849
|
+
if (q.resolvedAt == null && q.effectiveDueAt != null && q.effectiveDueAt <= now) {
|
|
850
|
+
add("quest.due", q.effectiveDueAt, { questId: q.id });
|
|
851
|
+
if (q.effectiveDueAt < now) add("quest.overdue", q.effectiveDueAt, { questId: q.id });
|
|
852
|
+
}
|
|
853
|
+
if (q.available) {
|
|
854
|
+
const prerequisites = db.prepare(
|
|
855
|
+
`SELECT p.id, p.resolved_at, u.created_at FROM quest_unlocks u JOIN quests p ON p.id = u.from_quest_id WHERE u.to_quest_id = ? AND u.deleted_at IS NULL AND p.deleted_at IS NULL AND p.success = 1 AND p.resolved_at IS NOT NULL`
|
|
856
|
+
).all(q.id);
|
|
857
|
+
if (prerequisites.some((p) => Number(p.resolved_at) >= Number(p.created_at))) {
|
|
858
|
+
const at = Math.max(q.notBeforeAt ?? 0, ...prerequisites.map((p) => Number(p.resolved_at)));
|
|
859
|
+
if (at <= now) add("quest.unblocked", at, { questId: q.id });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (q.spawnedFromRepeatableId != null && q.createdAt <= now)
|
|
863
|
+
add("repeatable.spawned", q.createdAt, {
|
|
864
|
+
questId: q.id,
|
|
865
|
+
repeatableId: q.spawnedFromRepeatableId
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
for (const row of db.prepare("SELECT id, created_at FROM rumors WHERE deleted_at IS NULL AND created_at <= ?").all(now))
|
|
869
|
+
add("rumor.captured", Number(row.created_at), { rumorId: Number(row.id) });
|
|
870
|
+
return events.sort(
|
|
871
|
+
(a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id)
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/hooks/tick_questlog.ts
|
|
876
|
+
async function tickQuestlog(db, options = {}) {
|
|
877
|
+
const now = options.now ?? options.clock?.() ?? Date.now();
|
|
878
|
+
const result = {
|
|
879
|
+
events: [],
|
|
880
|
+
batches: [],
|
|
881
|
+
sent: 0,
|
|
882
|
+
queued: 0,
|
|
883
|
+
retrying: 0,
|
|
884
|
+
failed: 0
|
|
885
|
+
};
|
|
886
|
+
const claims = [];
|
|
887
|
+
const collect = () => {
|
|
888
|
+
spawnDueRepeatableQuests(db, now);
|
|
889
|
+
const detected = detectHookEvents(db, now);
|
|
890
|
+
result.events = detected.filter(
|
|
891
|
+
(e) => !db.prepare("SELECT id FROM hook_events WHERE id = ?").get(e.id)
|
|
892
|
+
);
|
|
893
|
+
for (const event of result.events)
|
|
894
|
+
db.prepare("INSERT INTO hook_events(id, occurred_at, event) VALUES (?, ?, ?)").run(
|
|
895
|
+
event.id,
|
|
896
|
+
Date.parse(event.occurredAt),
|
|
897
|
+
JSON.stringify(event)
|
|
898
|
+
);
|
|
899
|
+
for (const target of listHooks(db)) {
|
|
900
|
+
const events = db.prepare(
|
|
901
|
+
"SELECT e.event FROM hook_events e WHERE occurred_at >= ? AND NOT EXISTS (SELECT 1 FROM hook_assignments a WHERE a.target_id = ? AND a.event_id = e.id) ORDER BY occurred_at, id"
|
|
902
|
+
).all(target.createdAt, target.id).map((row2) => JSON.parse(String(row2.event))).filter((event) => {
|
|
903
|
+
if (!target.events.includes(event.type)) return false;
|
|
904
|
+
if (target.tag == null) return true;
|
|
905
|
+
return event.questId !== void 0 && db.prepare(
|
|
906
|
+
"SELECT 1 FROM quest_tags qt JOIN tags t ON t.id = qt.tag_id WHERE qt.quest_id = ? AND qt.deleted_at IS NULL AND t.deleted_at IS NULL AND t.normalized_name = ?"
|
|
907
|
+
).get(event.questId, normalizeTagName(target.tag)) !== void 0;
|
|
908
|
+
});
|
|
909
|
+
for (const event of events)
|
|
910
|
+
db.prepare("INSERT INTO hook_assignments(target_id, event_id) VALUES (?, ?)").run(
|
|
911
|
+
target.id,
|
|
912
|
+
event.id
|
|
913
|
+
);
|
|
914
|
+
const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(target.id);
|
|
915
|
+
if (!row || row.lease_until != null && Number(row.lease_until) > now) continue;
|
|
916
|
+
let pending = listDeliveries(db).find(
|
|
917
|
+
(d) => d.targetId === target.id && d.status === "pending"
|
|
918
|
+
);
|
|
919
|
+
const queued = db.prepare(
|
|
920
|
+
"SELECT e.event FROM hook_assignments a JOIN hook_events e ON e.id = a.event_id WHERE a.target_id = ? AND a.delivery_id IS NULL ORDER BY e.occurred_at, e.id"
|
|
921
|
+
).all(target.id).map((r) => JSON.parse(String(r.event)));
|
|
922
|
+
if (!pending && queued.length) {
|
|
923
|
+
const id = `d_${node_crypto.randomBytes(16).toString("hex")}`;
|
|
924
|
+
db.prepare(
|
|
925
|
+
"INSERT INTO hook_deliveries(id, target_id, events, status, next_attempt_at) VALUES (?, ?, ?, 'pending', ?)"
|
|
926
|
+
).run(
|
|
927
|
+
id,
|
|
928
|
+
target.id,
|
|
929
|
+
JSON.stringify(queued),
|
|
930
|
+
Math.max(now, (target.lastWake ?? -Infinity) + target.minGap * 1e3)
|
|
931
|
+
);
|
|
932
|
+
db.prepare(
|
|
933
|
+
"UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
|
|
934
|
+
).run(id, target.id);
|
|
935
|
+
pending = listDeliveries(db).find((d) => d.id === id);
|
|
936
|
+
}
|
|
937
|
+
if (!pending) continue;
|
|
938
|
+
if (pending.attempts === 0 && queued.length && !queued.every((e) => pending?.events.some((p) => p.id === e.id))) {
|
|
939
|
+
pending.events.push(...queued);
|
|
940
|
+
db.prepare("UPDATE hook_deliveries SET events = ? WHERE id = ?").run(
|
|
941
|
+
JSON.stringify(pending.events),
|
|
942
|
+
pending.id
|
|
943
|
+
);
|
|
944
|
+
db.prepare(
|
|
945
|
+
"UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
|
|
946
|
+
).run(pending.id, target.id);
|
|
947
|
+
}
|
|
948
|
+
if (pending.nextAttemptAt > now || target.lastWake != null && target.lastWake + target.minGap * 1e3 > now)
|
|
949
|
+
continue;
|
|
950
|
+
if (pending.attempts >= 8) {
|
|
951
|
+
db.prepare("UPDATE hook_deliveries SET status = 'failed' WHERE id = ?").run(pending.id);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const claim = node_crypto.randomBytes(16).toString("hex");
|
|
955
|
+
db.prepare(
|
|
956
|
+
"UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
|
|
957
|
+
).run(claim, now + 12e4, now, target.id);
|
|
958
|
+
db.prepare(
|
|
959
|
+
"UPDATE hook_deliveries SET attempts = attempts + 1, last_attempt_at = ? WHERE id = ?"
|
|
960
|
+
).run(now, pending.id);
|
|
961
|
+
pending.attempts++;
|
|
962
|
+
pending.lastAttemptAt = now;
|
|
963
|
+
result.batches.push({ targetId: target.id, events: pending.events });
|
|
964
|
+
claims.push({
|
|
965
|
+
delivery: pending,
|
|
966
|
+
target: { id: target.id, url: String(row.url), secret: String(row.secret), claim }
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
if (options.dryRun) {
|
|
971
|
+
const rollback = new Error("preview rollback");
|
|
972
|
+
try {
|
|
973
|
+
withTransaction(db, () => {
|
|
974
|
+
collect();
|
|
975
|
+
throw rollback;
|
|
976
|
+
});
|
|
977
|
+
} catch (error) {
|
|
978
|
+
if (error !== rollback) throw error;
|
|
979
|
+
}
|
|
980
|
+
} else withTransaction(db, collect);
|
|
981
|
+
if (!options.dryRun) {
|
|
982
|
+
const sent = await Promise.all(
|
|
983
|
+
claims.map((claim) => sendHookDelivery(db, claim.delivery, claim.target, options))
|
|
984
|
+
);
|
|
985
|
+
result.sent = sent.filter(Boolean).length;
|
|
986
|
+
}
|
|
987
|
+
const deliveries = listDeliveries(db);
|
|
988
|
+
result.queued = deliveries.filter((d) => d.status === "pending" && d.attempts === 0).length + Number(
|
|
989
|
+
db.prepare(
|
|
990
|
+
"SELECT COUNT(DISTINCT target_id) AS n FROM hook_assignments WHERE delivery_id IS NULL"
|
|
991
|
+
).get()?.n ?? 0
|
|
992
|
+
);
|
|
993
|
+
result.retrying = deliveries.filter((d) => d.status === "pending" && d.attempts > 0).length;
|
|
994
|
+
result.failed = deliveries.filter((d) => d.status === "failed").length;
|
|
995
|
+
return result;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// src/hooks/watch_questlog.ts
|
|
999
|
+
async function watchQuestlog(db, options) {
|
|
1000
|
+
const interval = options.interval ?? 60;
|
|
1001
|
+
if (!Number.isFinite(interval) || interval < 5)
|
|
1002
|
+
throw new Error("Interval must be at least 5 seconds");
|
|
1003
|
+
while (!options.signal.aborted) {
|
|
1004
|
+
options.onTick?.(await tickQuestlog(db, options));
|
|
1005
|
+
if (options.signal.aborted) break;
|
|
1006
|
+
await new Promise((resolve) => {
|
|
1007
|
+
const finish = () => {
|
|
1008
|
+
clearTimeout(timer);
|
|
1009
|
+
options.signal.removeEventListener("abort", finish);
|
|
1010
|
+
resolve();
|
|
1011
|
+
};
|
|
1012
|
+
const timer = setTimeout(finish, interval * 1e3);
|
|
1013
|
+
options.signal.addEventListener("abort", finish, { once: true });
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/hooks/init_hook_tables.ts
|
|
1019
|
+
function initHookTables(db) {
|
|
1020
|
+
db.exec(`
|
|
1021
|
+
CREATE TABLE IF NOT EXISTS hook_targets (
|
|
1022
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, secret TEXT NOT NULL,
|
|
1023
|
+
tag TEXT, events TEXT NOT NULL, min_gap INTEGER NOT NULL, created_at INTEGER NOT NULL,
|
|
1024
|
+
last_wake INTEGER, lease_until INTEGER, claim TEXT);
|
|
1025
|
+
CREATE TABLE IF NOT EXISTS hook_events (
|
|
1026
|
+
id TEXT PRIMARY KEY, occurred_at INTEGER NOT NULL, event TEXT NOT NULL);
|
|
1027
|
+
CREATE TABLE IF NOT EXISTS hook_assignments (
|
|
1028
|
+
target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
|
|
1029
|
+
event_id TEXT NOT NULL REFERENCES hook_events(id), delivery_id TEXT,
|
|
1030
|
+
PRIMARY KEY(target_id, event_id));
|
|
1031
|
+
CREATE TABLE IF NOT EXISTS hook_deliveries (
|
|
1032
|
+
id TEXT PRIMARY KEY, target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
|
|
1033
|
+
events TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
1034
|
+
last_attempt_at INTEGER, http_status INTEGER, error_class TEXT,
|
|
1035
|
+
next_attempt_at INTEGER NOT NULL);
|
|
1036
|
+
CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
|
|
1037
|
+
CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
|
|
1038
|
+
`);
|
|
1039
|
+
if (!db.prepare("PRAGMA table_info(hook_targets)").all().some((row) => row.name === "headers"))
|
|
1040
|
+
db.exec("ALTER TABLE hook_targets ADD COLUMN headers TEXT NOT NULL DEFAULT '{}'");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
9
1043
|
// src/questlines/init_questline_tables.ts
|
|
10
1044
|
function initQuestlineTables(db) {
|
|
11
1045
|
db.exec(`
|
|
@@ -354,6 +1388,7 @@ function initQuestlogTables(db) {
|
|
|
354
1388
|
initTagTables(db);
|
|
355
1389
|
initQuestSearch(db);
|
|
356
1390
|
initRumorSearch(db);
|
|
1391
|
+
initHookTables(db);
|
|
357
1392
|
}
|
|
358
1393
|
|
|
359
1394
|
// src/markers/index.ts
|
|
@@ -364,36 +1399,6 @@ __export(markers_exports, {
|
|
|
364
1399
|
markerLookup: () => markerLookup
|
|
365
1400
|
});
|
|
366
1401
|
|
|
367
|
-
// src/markers/derive_quest_marker_id.ts
|
|
368
|
-
function deriveQuestMarkerId({
|
|
369
|
-
state,
|
|
370
|
-
available,
|
|
371
|
-
hasUnclaimedReward,
|
|
372
|
-
notBeforeAt,
|
|
373
|
-
blockerCount,
|
|
374
|
-
now
|
|
375
|
-
}) {
|
|
376
|
-
if (state === "done") {
|
|
377
|
-
return hasUnclaimedReward ? "progress.complete" : null;
|
|
378
|
-
}
|
|
379
|
-
if (state === "abandoned") {
|
|
380
|
-
return null;
|
|
381
|
-
}
|
|
382
|
-
if (state === "in_progress") {
|
|
383
|
-
return "progress.incomplete";
|
|
384
|
-
}
|
|
385
|
-
if (available) {
|
|
386
|
-
return "attention.available";
|
|
387
|
-
}
|
|
388
|
-
if (notBeforeAt != null && notBeforeAt > now) {
|
|
389
|
-
return "attention.available.future";
|
|
390
|
-
}
|
|
391
|
-
if (blockerCount > 0) {
|
|
392
|
-
return "progress.incomplete";
|
|
393
|
-
}
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
1402
|
// src/markers/derive_rumor_marker_id.ts
|
|
398
1403
|
function deriveRumorMarkerId({ state }) {
|
|
399
1404
|
if (state === "open") {
|
|
@@ -481,11 +1486,6 @@ __export(read_exports, {
|
|
|
481
1486
|
searchQuestlog: () => searchQuestlog
|
|
482
1487
|
});
|
|
483
1488
|
|
|
484
|
-
// src/resolve_now.ts
|
|
485
|
-
function resolveNow(now) {
|
|
486
|
-
return now ?? Date.now();
|
|
487
|
-
}
|
|
488
|
-
|
|
489
1489
|
// src/assert_non_empty.ts
|
|
490
1490
|
function assertNonEmpty(value, label) {
|
|
491
1491
|
const trimmed = value.trim();
|
|
@@ -495,41 +1495,6 @@ function assertNonEmpty(value, label) {
|
|
|
495
1495
|
return trimmed;
|
|
496
1496
|
}
|
|
497
1497
|
|
|
498
|
-
// src/with_transaction.ts
|
|
499
|
-
var transactionDepth = /* @__PURE__ */ new WeakMap();
|
|
500
|
-
function withTransaction(db, fn) {
|
|
501
|
-
const depth = transactionDepth.get(db) ?? 0;
|
|
502
|
-
const savepoint = `questlog_${depth}`;
|
|
503
|
-
if (depth === 0) {
|
|
504
|
-
db.exec("BEGIN IMMEDIATE");
|
|
505
|
-
} else {
|
|
506
|
-
db.exec(`SAVEPOINT ${savepoint}`);
|
|
507
|
-
}
|
|
508
|
-
transactionDepth.set(db, depth + 1);
|
|
509
|
-
try {
|
|
510
|
-
const result = fn();
|
|
511
|
-
if (depth === 0) {
|
|
512
|
-
db.exec("COMMIT");
|
|
513
|
-
} else {
|
|
514
|
-
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
515
|
-
}
|
|
516
|
-
transactionDepth.set(db, depth);
|
|
517
|
-
return result;
|
|
518
|
-
} catch (error) {
|
|
519
|
-
try {
|
|
520
|
-
if (depth === 0) {
|
|
521
|
-
db.exec("ROLLBACK");
|
|
522
|
-
} else {
|
|
523
|
-
db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
524
|
-
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
525
|
-
}
|
|
526
|
-
} catch {
|
|
527
|
-
}
|
|
528
|
-
transactionDepth.set(db, depth);
|
|
529
|
-
throw error;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
1498
|
// src/questlines/map_questline_row.ts
|
|
534
1499
|
function mapQuestlineRow(row) {
|
|
535
1500
|
return {
|
|
@@ -758,46 +1723,6 @@ function softDeleteQuestline(db, questlineId, deletedAt) {
|
|
|
758
1723
|
).run(now, now, questlineId);
|
|
759
1724
|
}
|
|
760
1725
|
|
|
761
|
-
// src/quests/map_quest_row.ts
|
|
762
|
-
function asBoolean(value) {
|
|
763
|
-
return Number(value) === 1;
|
|
764
|
-
}
|
|
765
|
-
function mapQuestRow(row) {
|
|
766
|
-
return {
|
|
767
|
-
id: Number(row.id),
|
|
768
|
-
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
769
|
-
sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
|
|
770
|
-
spawnedByQuestId: row.spawned_by_quest_id == null ? null : Number(row.spawned_by_quest_id),
|
|
771
|
-
spawnedFromRepeatableId: row.spawned_from_repeatable_id == null ? null : Number(row.spawned_from_repeatable_id),
|
|
772
|
-
spawnedForAt: row.spawned_for_at == null ? null : Number(row.spawned_for_at),
|
|
773
|
-
title: String(row.title),
|
|
774
|
-
objective: String(row.objective),
|
|
775
|
-
outcome: row.outcome == null ? null : String(row.outcome),
|
|
776
|
-
createdAt: Number(row.created_at),
|
|
777
|
-
updatedAt: Number(row.updated_at),
|
|
778
|
-
startedAt: row.started_at == null ? null : Number(row.started_at),
|
|
779
|
-
resolvedAt: row.resolved_at == null ? null : Number(row.resolved_at),
|
|
780
|
-
success: row.success == null ? null : asBoolean(row.success),
|
|
781
|
-
effortSeconds: Number(row.effort_seconds),
|
|
782
|
-
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
783
|
-
notBeforeAt: row.not_before_at == null ? null : Number(row.not_before_at),
|
|
784
|
-
dueAt: row.due_at == null ? null : Number(row.due_at),
|
|
785
|
-
scheduledStartAt: row.scheduled_start_at == null ? null : Number(row.scheduled_start_at),
|
|
786
|
-
scheduledEndAt: row.scheduled_end_at == null ? null : Number(row.scheduled_end_at),
|
|
787
|
-
allDay: asBoolean(row.all_day),
|
|
788
|
-
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// src/quests/get_quest_or_throw.ts
|
|
793
|
-
function getQuestOrThrow(db, questId) {
|
|
794
|
-
const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
|
|
795
|
-
if (!row) {
|
|
796
|
-
throw new Error(`Quest ${questId} was not found.`);
|
|
797
|
-
}
|
|
798
|
-
return mapQuestRow(row);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
1726
|
// src/quests/resolve_quest.ts
|
|
802
1727
|
function resolveQuest(db, questId, success, outcome, resolvedAt) {
|
|
803
1728
|
const current = getQuestOrThrow(db, questId);
|
|
@@ -899,11 +1824,6 @@ function replaceActiveQuestRewards(db, questId, rewards, now) {
|
|
|
899
1824
|
}
|
|
900
1825
|
}
|
|
901
1826
|
|
|
902
|
-
// src/tags/normalize_tag_name.ts
|
|
903
|
-
function normalizeTagName(name) {
|
|
904
|
-
return name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
905
|
-
}
|
|
906
|
-
|
|
907
1827
|
// src/tags/replace_active_quest_tags.ts
|
|
908
1828
|
function getOrCreateTagId(db, name, now) {
|
|
909
1829
|
const normalizedName = normalizeTagName(name);
|
|
@@ -1000,215 +1920,42 @@ function createQuest(db, input) {
|
|
|
1000
1920
|
if (input.tags && input.tags.length > 0) {
|
|
1001
1921
|
replaceActiveQuestTags(db, questId, input.tags, now);
|
|
1002
1922
|
}
|
|
1003
|
-
if (input.rewards && input.rewards.length > 0) {
|
|
1004
|
-
replaceActiveQuestRewards(db, questId, input.rewards, now);
|
|
1005
|
-
}
|
|
1006
|
-
return getQuestOrThrow(db, questId);
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// src/quests/abandon_quest_and_spawn_followups.ts
|
|
1011
|
-
function abandonQuestAndSpawnFollowups(db, questId, outcome, followups, resolvedAt) {
|
|
1012
|
-
return withTransaction(db, () => {
|
|
1013
|
-
const abandonedQuest = abandonQuest(db, questId, outcome, resolvedAt);
|
|
1014
|
-
const now = resolveNow(resolvedAt);
|
|
1015
|
-
const followupQuests = followups.map(
|
|
1016
|
-
(followup) => createQuest(db, {
|
|
1017
|
-
...followup,
|
|
1018
|
-
questlineId: followup.questlineId ?? abandonedQuest.questlineId,
|
|
1019
|
-
spawnedByQuestId: questId,
|
|
1020
|
-
now
|
|
1021
|
-
})
|
|
1022
|
-
);
|
|
1023
|
-
for (let i = 0; i < followups.length; i += 1) {
|
|
1024
|
-
const followup = followups[i];
|
|
1025
|
-
const created = followupQuests[i];
|
|
1026
|
-
if (followup?.unlocksParent && created) {
|
|
1027
|
-
addUnlock(db, created.id, questId, resolvedAt);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
return { abandonedQuest, followupQuests };
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// src/quests/finish_quest.ts
|
|
1035
|
-
function finishQuest(db, questId, outcome, resolvedAt) {
|
|
1036
|
-
return resolveQuest(db, questId, true, outcome, resolvedAt);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// src/rewards/map_quest_reward_row.ts
|
|
1040
|
-
function mapQuestRewardRow(row) {
|
|
1041
|
-
return {
|
|
1042
|
-
id: Number(row.id),
|
|
1043
|
-
questId: Number(row.quest_id),
|
|
1044
|
-
kind: String(row.kind),
|
|
1045
|
-
name: String(row.name),
|
|
1046
|
-
description: row.description == null ? null : String(row.description),
|
|
1047
|
-
quantity: row.quantity == null ? null : Number(row.quantity),
|
|
1048
|
-
createdAt: Number(row.created_at),
|
|
1049
|
-
claimedAt: row.claimed_at == null ? null : Number(row.claimed_at),
|
|
1050
|
-
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// src/quests/derive_quest_state.ts
|
|
1055
|
-
function deriveQuestState(quest) {
|
|
1056
|
-
if (quest.resolvedAt != null && quest.success === true) {
|
|
1057
|
-
return "done";
|
|
1058
|
-
}
|
|
1059
|
-
if (quest.resolvedAt != null && quest.success === false) {
|
|
1060
|
-
return "abandoned";
|
|
1061
|
-
}
|
|
1062
|
-
if (quest.startedAt != null) {
|
|
1063
|
-
return "in_progress";
|
|
1064
|
-
}
|
|
1065
|
-
return "open";
|
|
1923
|
+
if (input.rewards && input.rewards.length > 0) {
|
|
1924
|
+
replaceActiveQuestRewards(db, questId, input.rewards, now);
|
|
1925
|
+
}
|
|
1926
|
+
return getQuestOrThrow(db, questId);
|
|
1927
|
+
});
|
|
1066
1928
|
}
|
|
1067
1929
|
|
|
1068
|
-
// src/quests/
|
|
1069
|
-
function
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
const normalized = [...new Set(filters.tagNames.map(normalizeTagName).filter(Boolean))];
|
|
1088
|
-
for (const tag of normalized) {
|
|
1089
|
-
clauses.push(
|
|
1090
|
-
`EXISTS (
|
|
1091
|
-
SELECT 1
|
|
1092
|
-
FROM quest_tags qt
|
|
1093
|
-
JOIN tags t ON t.id = qt.tag_id
|
|
1094
|
-
WHERE qt.quest_id = q.id
|
|
1095
|
-
AND qt.deleted_at IS NULL
|
|
1096
|
-
AND t.deleted_at IS NULL
|
|
1097
|
-
AND t.normalized_name = ?
|
|
1098
|
-
)`
|
|
1099
|
-
);
|
|
1100
|
-
params.push(tag);
|
|
1930
|
+
// src/quests/abandon_quest_and_spawn_followups.ts
|
|
1931
|
+
function abandonQuestAndSpawnFollowups(db, questId, outcome, followups, resolvedAt) {
|
|
1932
|
+
return withTransaction(db, () => {
|
|
1933
|
+
const abandonedQuest = abandonQuest(db, questId, outcome, resolvedAt);
|
|
1934
|
+
const now = resolveNow(resolvedAt);
|
|
1935
|
+
const followupQuests = followups.map(
|
|
1936
|
+
(followup) => createQuest(db, {
|
|
1937
|
+
...followup,
|
|
1938
|
+
questlineId: followup.questlineId ?? abandonedQuest.questlineId,
|
|
1939
|
+
spawnedByQuestId: questId,
|
|
1940
|
+
now
|
|
1941
|
+
})
|
|
1942
|
+
);
|
|
1943
|
+
for (let i = 0; i < followups.length; i += 1) {
|
|
1944
|
+
const followup = followups[i];
|
|
1945
|
+
const created = followupQuests[i];
|
|
1946
|
+
if (followup?.unlocksParent && created) {
|
|
1947
|
+
addUnlock(db, created.id, questId, resolvedAt);
|
|
1948
|
+
}
|
|
1101
1949
|
}
|
|
1102
|
-
|
|
1103
|
-
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1104
|
-
const rows = db.prepare(
|
|
1105
|
-
`SELECT q.*,
|
|
1106
|
-
COALESCE(q.due_at, ql.due_at) AS effective_due_at,
|
|
1107
|
-
(
|
|
1108
|
-
SELECT COUNT(*)
|
|
1109
|
-
FROM quest_unlocks qu
|
|
1110
|
-
JOIN quests blocker ON blocker.id = qu.from_quest_id
|
|
1111
|
-
WHERE qu.to_quest_id = q.id
|
|
1112
|
-
AND qu.deleted_at IS NULL
|
|
1113
|
-
AND blocker.deleted_at IS NULL
|
|
1114
|
-
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
1115
|
-
) AS blocker_count
|
|
1116
|
-
FROM quests q
|
|
1117
|
-
LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
|
|
1118
|
-
${where}
|
|
1119
|
-
ORDER BY q.created_at DESC, q.id DESC`
|
|
1120
|
-
).all(...params);
|
|
1121
|
-
if (rows.length === 0) {
|
|
1122
|
-
return [];
|
|
1123
|
-
}
|
|
1124
|
-
const ids = rows.map((row) => Number(row.id));
|
|
1125
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
1126
|
-
const tagNamesByQuestId = /* @__PURE__ */ new Map();
|
|
1127
|
-
const rewardsByQuestId = /* @__PURE__ */ new Map();
|
|
1128
|
-
const unlocksByQuestId = /* @__PURE__ */ new Map();
|
|
1129
|
-
const unlockedByQuestId = /* @__PURE__ */ new Map();
|
|
1130
|
-
for (const row of db.prepare(
|
|
1131
|
-
`SELECT qt.quest_id, t.name
|
|
1132
|
-
FROM quest_tags qt
|
|
1133
|
-
JOIN tags t ON t.id = qt.tag_id
|
|
1134
|
-
WHERE qt.quest_id IN (${placeholders})
|
|
1135
|
-
AND qt.deleted_at IS NULL
|
|
1136
|
-
AND t.deleted_at IS NULL
|
|
1137
|
-
ORDER BY qt.quest_id, t.name`
|
|
1138
|
-
).all(...ids)) {
|
|
1139
|
-
const questId = Number(row.quest_id);
|
|
1140
|
-
const tagNames = tagNamesByQuestId.get(questId) ?? [];
|
|
1141
|
-
tagNames.push(String(row.name));
|
|
1142
|
-
tagNamesByQuestId.set(questId, tagNames);
|
|
1143
|
-
}
|
|
1144
|
-
for (const row of db.prepare(
|
|
1145
|
-
`SELECT *
|
|
1146
|
-
FROM quest_rewards
|
|
1147
|
-
WHERE quest_id IN (${placeholders})
|
|
1148
|
-
AND deleted_at IS NULL
|
|
1149
|
-
ORDER BY quest_id, created_at, id`
|
|
1150
|
-
).all(...ids)) {
|
|
1151
|
-
const reward = mapQuestRewardRow(row);
|
|
1152
|
-
const rewards = rewardsByQuestId.get(reward.questId) ?? [];
|
|
1153
|
-
rewards.push(reward);
|
|
1154
|
-
rewardsByQuestId.set(reward.questId, rewards);
|
|
1155
|
-
}
|
|
1156
|
-
for (const row of db.prepare(
|
|
1157
|
-
`SELECT from_quest_id, to_quest_id
|
|
1158
|
-
FROM quest_unlocks
|
|
1159
|
-
WHERE from_quest_id IN (${placeholders})
|
|
1160
|
-
AND deleted_at IS NULL
|
|
1161
|
-
ORDER BY from_quest_id, to_quest_id`
|
|
1162
|
-
).all(...ids)) {
|
|
1163
|
-
const questId = Number(row.from_quest_id);
|
|
1164
|
-
const unlocks = unlocksByQuestId.get(questId) ?? [];
|
|
1165
|
-
unlocks.push(Number(row.to_quest_id));
|
|
1166
|
-
unlocksByQuestId.set(questId, unlocks);
|
|
1167
|
-
}
|
|
1168
|
-
for (const row of db.prepare(
|
|
1169
|
-
`SELECT from_quest_id, to_quest_id
|
|
1170
|
-
FROM quest_unlocks
|
|
1171
|
-
WHERE to_quest_id IN (${placeholders})
|
|
1172
|
-
AND deleted_at IS NULL
|
|
1173
|
-
ORDER BY to_quest_id, from_quest_id`
|
|
1174
|
-
).all(...ids)) {
|
|
1175
|
-
const questId = Number(row.to_quest_id);
|
|
1176
|
-
const unlockedBy = unlockedByQuestId.get(questId) ?? [];
|
|
1177
|
-
unlockedBy.push(Number(row.from_quest_id));
|
|
1178
|
-
unlockedByQuestId.set(questId, unlockedBy);
|
|
1179
|
-
}
|
|
1180
|
-
return rows.map((row) => {
|
|
1181
|
-
const quest = mapQuestRow(row);
|
|
1182
|
-
const blockerCount = Number(row.blocker_count ?? 0);
|
|
1183
|
-
const effectiveDueAt = row.effective_due_at == null ? null : Number(row.effective_due_at);
|
|
1184
|
-
const rewards = rewardsByQuestId.get(quest.id) ?? [];
|
|
1185
|
-
const state = deriveQuestState(quest);
|
|
1186
|
-
const available = quest.deletedAt == null && quest.resolvedAt == null && (quest.notBeforeAt == null || quest.notBeforeAt <= now) && blockerCount === 0;
|
|
1187
|
-
const markerId = deriveQuestMarkerId({
|
|
1188
|
-
state,
|
|
1189
|
-
available,
|
|
1190
|
-
hasUnclaimedReward: rewards.some((reward) => reward.claimedAt == null),
|
|
1191
|
-
notBeforeAt: quest.notBeforeAt,
|
|
1192
|
-
blockerCount,
|
|
1193
|
-
now
|
|
1194
|
-
});
|
|
1195
|
-
return {
|
|
1196
|
-
detail: {
|
|
1197
|
-
...quest,
|
|
1198
|
-
state,
|
|
1199
|
-
markerId,
|
|
1200
|
-
effectiveDueAt,
|
|
1201
|
-
available,
|
|
1202
|
-
tagNames: tagNamesByQuestId.get(quest.id) ?? [],
|
|
1203
|
-
rewards,
|
|
1204
|
-
unlocksQuestIds: unlocksByQuestId.get(quest.id) ?? [],
|
|
1205
|
-
unlockedByQuestIds: unlockedByQuestId.get(quest.id) ?? []
|
|
1206
|
-
},
|
|
1207
|
-
blockerCount
|
|
1208
|
-
};
|
|
1950
|
+
return { abandonedQuest, followupQuests };
|
|
1209
1951
|
});
|
|
1210
1952
|
}
|
|
1211
1953
|
|
|
1954
|
+
// src/quests/finish_quest.ts
|
|
1955
|
+
function finishQuest(db, questId, outcome, resolvedAt) {
|
|
1956
|
+
return resolveQuest(db, questId, true, outcome, resolvedAt);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1212
1959
|
// src/quests/get_quest_detail.ts
|
|
1213
1960
|
function getQuestDetail(db, questId, now = Date.now()) {
|
|
1214
1961
|
const [record] = queryQuestDetailRecords(db, {}, now, [questId]);
|
|
@@ -1381,92 +2128,15 @@ function startQuest(db, questId, startedAt) {
|
|
|
1381
2128
|
return current;
|
|
1382
2129
|
}
|
|
1383
2130
|
if (current.resolvedAt != null) {
|
|
1384
|
-
throw new Error("Resolved quests cannot be started.");
|
|
1385
|
-
}
|
|
1386
|
-
const now = resolveNow(startedAt);
|
|
1387
|
-
db.prepare(
|
|
1388
|
-
`UPDATE quests
|
|
1389
|
-
SET started_at = ?, updated_at = ?
|
|
1390
|
-
WHERE id = ? AND deleted_at IS NULL`
|
|
1391
|
-
).run(now, now, questId);
|
|
1392
|
-
return getQuestOrThrow(db, questId);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// src/repeatable_quests/map_repeatable_quest_row.ts
|
|
1396
|
-
function asBoolean2(value) {
|
|
1397
|
-
return Number(value) === 1;
|
|
1398
|
-
}
|
|
1399
|
-
function mapRepeatableQuestRow(row) {
|
|
1400
|
-
return {
|
|
1401
|
-
id: Number(row.id),
|
|
1402
|
-
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
1403
|
-
title: String(row.title),
|
|
1404
|
-
objective: String(row.objective),
|
|
1405
|
-
rrule: String(row.rrule),
|
|
1406
|
-
anchorAt: Number(row.anchor_at),
|
|
1407
|
-
notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
|
|
1408
|
-
dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
|
|
1409
|
-
scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
|
|
1410
|
-
scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
|
|
1411
|
-
allDay: asBoolean2(row.all_day),
|
|
1412
|
-
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
1413
|
-
createdAt: Number(row.created_at),
|
|
1414
|
-
updatedAt: Number(row.updated_at),
|
|
1415
|
-
archivedAt: row.archived_at == null ? null : Number(row.archived_at),
|
|
1416
|
-
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
// src/repeatable_quests/get_repeatable_quest_or_throw.ts
|
|
1421
|
-
function getRepeatableQuestOrThrow(db, repeatableQuestId) {
|
|
1422
|
-
const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
|
|
1423
|
-
if (!row) {
|
|
1424
|
-
throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
|
|
1425
|
-
}
|
|
1426
|
-
return mapRepeatableQuestRow(row);
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
// src/repeatable_quests/parse_rrule.ts
|
|
1430
|
-
var WEEKDAY = {
|
|
1431
|
-
SU: 0,
|
|
1432
|
-
MO: 1,
|
|
1433
|
-
TU: 2,
|
|
1434
|
-
WE: 3,
|
|
1435
|
-
TH: 4,
|
|
1436
|
-
FR: 5,
|
|
1437
|
-
SA: 6
|
|
1438
|
-
};
|
|
1439
|
-
function parseRRule(rrule) {
|
|
1440
|
-
const parts = Object.fromEntries(
|
|
1441
|
-
rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
1442
|
-
const [key, value] = part.split("=");
|
|
1443
|
-
return [key, value];
|
|
1444
|
-
})
|
|
1445
|
-
);
|
|
1446
|
-
const freq = parts.FREQ;
|
|
1447
|
-
if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
|
|
1448
|
-
throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
|
|
1449
|
-
}
|
|
1450
|
-
const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
|
|
1451
|
-
if (!Number.isFinite(interval) || interval < 1) {
|
|
1452
|
-
throw new Error("RRULE INTERVAL must be a positive integer.");
|
|
1453
|
-
}
|
|
1454
|
-
const count = parts.COUNT == null ? null : Number(parts.COUNT);
|
|
1455
|
-
if (count != null && (!Number.isFinite(count) || count < 1)) {
|
|
1456
|
-
throw new Error("RRULE COUNT must be a positive integer.");
|
|
1457
|
-
}
|
|
1458
|
-
const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
|
|
1459
|
-
if (parts.UNTIL != null && Number.isNaN(until)) {
|
|
1460
|
-
throw new Error("RRULE UNTIL must be parseable as a date.");
|
|
1461
|
-
}
|
|
1462
|
-
const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
|
|
1463
|
-
const value = WEEKDAY[token];
|
|
1464
|
-
if (value == null) {
|
|
1465
|
-
throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
|
|
1466
|
-
}
|
|
1467
|
-
return value;
|
|
1468
|
-
});
|
|
1469
|
-
return { freq, interval, count, until, byDay };
|
|
2131
|
+
throw new Error("Resolved quests cannot be started.");
|
|
2132
|
+
}
|
|
2133
|
+
const now = resolveNow(startedAt);
|
|
2134
|
+
db.prepare(
|
|
2135
|
+
`UPDATE quests
|
|
2136
|
+
SET started_at = ?, updated_at = ?
|
|
2137
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
2138
|
+
).run(now, now, questId);
|
|
2139
|
+
return getQuestOrThrow(db, questId);
|
|
1470
2140
|
}
|
|
1471
2141
|
|
|
1472
2142
|
// src/repeatable_quests/update_repeatable_quest.ts
|
|
@@ -1627,138 +2297,6 @@ function createRepeatableQuest(db, input) {
|
|
|
1627
2297
|
});
|
|
1628
2298
|
}
|
|
1629
2299
|
|
|
1630
|
-
// src/repeatable_quests/advance_anchor.ts
|
|
1631
|
-
var WEEK_MS = 7 * 864e5;
|
|
1632
|
-
function advanceAnchor(anchorAt, freq, interval) {
|
|
1633
|
-
const date = new Date(anchorAt);
|
|
1634
|
-
if (freq === "MONTHLY") {
|
|
1635
|
-
date.setUTCMonth(date.getUTCMonth() + interval);
|
|
1636
|
-
return date.getTime();
|
|
1637
|
-
}
|
|
1638
|
-
if (freq === "YEARLY") {
|
|
1639
|
-
date.setUTCFullYear(date.getUTCFullYear() + interval);
|
|
1640
|
-
return date.getTime();
|
|
1641
|
-
}
|
|
1642
|
-
return anchorAt + interval * WEEK_MS;
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
// src/repeatable_quests/start_of_utc_day.ts
|
|
1646
|
-
function startOfUtcDay(timestamp) {
|
|
1647
|
-
const date = new Date(timestamp);
|
|
1648
|
-
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
// src/repeatable_quests/start_of_utc_week.ts
|
|
1652
|
-
var DAY_MS = 864e5;
|
|
1653
|
-
function startOfUtcWeek(dayStart) {
|
|
1654
|
-
const date = new Date(dayStart);
|
|
1655
|
-
return dayStart - date.getUTCDay() * DAY_MS;
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
// src/repeatable_quests/generate_anchors_until.ts
|
|
1659
|
-
var DAY_MS2 = 864e5;
|
|
1660
|
-
var WEEK_MS2 = 7 * DAY_MS2;
|
|
1661
|
-
function generateAnchorsUntil(repeatable, now) {
|
|
1662
|
-
const rule = parseRRule(repeatable.rrule);
|
|
1663
|
-
const anchors = [];
|
|
1664
|
-
const until = rule.until == null ? now : Math.min(now, rule.until);
|
|
1665
|
-
if (repeatable.anchorAt > until) {
|
|
1666
|
-
return anchors;
|
|
1667
|
-
}
|
|
1668
|
-
if (rule.freq === "DAILY") {
|
|
1669
|
-
let occurrence2 = 0;
|
|
1670
|
-
for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
|
|
1671
|
-
occurrence2 += 1;
|
|
1672
|
-
if (rule.count != null && occurrence2 > rule.count) {
|
|
1673
|
-
break;
|
|
1674
|
-
}
|
|
1675
|
-
anchors.push(current2);
|
|
1676
|
-
}
|
|
1677
|
-
return anchors;
|
|
1678
|
-
}
|
|
1679
|
-
if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
|
|
1680
|
-
const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
|
|
1681
|
-
const anchorWeekStart = startOfUtcWeek(anchorDayStart);
|
|
1682
|
-
const timeOffset = repeatable.anchorAt - anchorDayStart;
|
|
1683
|
-
let occurrence2 = 0;
|
|
1684
|
-
for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
|
|
1685
|
-
const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
|
|
1686
|
-
if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
|
|
1687
|
-
continue;
|
|
1688
|
-
}
|
|
1689
|
-
if (!rule.byDay.includes(new Date(day).getUTCDay())) {
|
|
1690
|
-
continue;
|
|
1691
|
-
}
|
|
1692
|
-
const anchor = day + timeOffset;
|
|
1693
|
-
if (anchor < repeatable.anchorAt || anchor > until) {
|
|
1694
|
-
continue;
|
|
1695
|
-
}
|
|
1696
|
-
occurrence2 += 1;
|
|
1697
|
-
if (rule.count != null && occurrence2 > rule.count) {
|
|
1698
|
-
break;
|
|
1699
|
-
}
|
|
1700
|
-
anchors.push(anchor);
|
|
1701
|
-
}
|
|
1702
|
-
return anchors;
|
|
1703
|
-
}
|
|
1704
|
-
let occurrence = 0;
|
|
1705
|
-
let current = repeatable.anchorAt;
|
|
1706
|
-
while (current <= until) {
|
|
1707
|
-
occurrence += 1;
|
|
1708
|
-
if (rule.count != null && occurrence > rule.count) {
|
|
1709
|
-
break;
|
|
1710
|
-
}
|
|
1711
|
-
anchors.push(current);
|
|
1712
|
-
current = advanceAnchor(current, rule.freq, rule.interval);
|
|
1713
|
-
}
|
|
1714
|
-
return anchors;
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
// src/repeatable_quests/list_due_repeatable_quest_anchors.ts
|
|
1718
|
-
function listDueRepeatableQuestAnchors(db, now) {
|
|
1719
|
-
const repeatables = db.prepare(
|
|
1720
|
-
`SELECT *
|
|
1721
|
-
FROM repeatable_quests
|
|
1722
|
-
WHERE deleted_at IS NULL
|
|
1723
|
-
AND archived_at IS NULL`
|
|
1724
|
-
).all().map(mapRepeatableQuestRow);
|
|
1725
|
-
const anchors = [];
|
|
1726
|
-
if (repeatables.length === 0) {
|
|
1727
|
-
return anchors;
|
|
1728
|
-
}
|
|
1729
|
-
const repeatableIds = repeatables.map((repeatable) => repeatable.id);
|
|
1730
|
-
const maxAnchorAt = Math.max(
|
|
1731
|
-
...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
|
|
1732
|
-
);
|
|
1733
|
-
const existingRows = db.prepare(
|
|
1734
|
-
`SELECT spawned_from_repeatable_id, spawned_for_at
|
|
1735
|
-
FROM quests
|
|
1736
|
-
WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
|
|
1737
|
-
AND spawned_for_at IS NOT NULL
|
|
1738
|
-
AND spawned_for_at <= ?`
|
|
1739
|
-
).all(...repeatableIds, maxAnchorAt);
|
|
1740
|
-
const existingAnchors = new Set(
|
|
1741
|
-
existingRows.map(
|
|
1742
|
-
(row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
|
|
1743
|
-
)
|
|
1744
|
-
);
|
|
1745
|
-
for (const repeatable of repeatables) {
|
|
1746
|
-
const dueAnchors = generateAnchorsUntil(repeatable, now);
|
|
1747
|
-
for (const anchorAt of dueAnchors) {
|
|
1748
|
-
if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
|
|
1749
|
-
anchors.push({
|
|
1750
|
-
repeatableQuestId: repeatable.id,
|
|
1751
|
-
anchorAt,
|
|
1752
|
-
markerId: "attention.available.repeatable"
|
|
1753
|
-
});
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
return anchors.sort(
|
|
1758
|
-
(a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
|
|
1759
|
-
);
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
2300
|
// src/repeatable_quests/soft_delete_repeatable_quest.ts
|
|
1763
2301
|
function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
|
|
1764
2302
|
const now = resolveNow(deletedAt);
|
|
@@ -1770,95 +2308,6 @@ function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
|
|
|
1770
2308
|
).run(now, now, repeatableQuestId);
|
|
1771
2309
|
}
|
|
1772
2310
|
|
|
1773
|
-
// src/rewards/copy_repeatable_quest_rewards_to_quest.ts
|
|
1774
|
-
function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
|
|
1775
|
-
const effectiveNow = resolveNow(now);
|
|
1776
|
-
const rows = db.prepare(
|
|
1777
|
-
`SELECT kind, name, description, quantity
|
|
1778
|
-
FROM repeatable_quest_rewards
|
|
1779
|
-
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
1780
|
-
).all(repeatableQuestId);
|
|
1781
|
-
for (const row of rows) {
|
|
1782
|
-
db.prepare(
|
|
1783
|
-
`INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
|
|
1784
|
-
VALUES (?, ?, ?, ?, ?, ?)`
|
|
1785
|
-
).run(
|
|
1786
|
-
questId,
|
|
1787
|
-
String(row.kind),
|
|
1788
|
-
String(row.name),
|
|
1789
|
-
row.description == null ? null : String(row.description),
|
|
1790
|
-
row.quantity == null ? null : Number(row.quantity),
|
|
1791
|
-
effectiveNow
|
|
1792
|
-
);
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// src/tags/copy_repeatable_quest_tags_to_quest.ts
|
|
1797
|
-
function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
|
|
1798
|
-
const effectiveNow = resolveNow(now);
|
|
1799
|
-
const rows = db.prepare(
|
|
1800
|
-
`SELECT tag_id
|
|
1801
|
-
FROM repeatable_quest_tags
|
|
1802
|
-
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
1803
|
-
).all(repeatableQuestId);
|
|
1804
|
-
for (const row of rows) {
|
|
1805
|
-
db.prepare(
|
|
1806
|
-
`INSERT INTO quest_tags (quest_id, tag_id, created_at)
|
|
1807
|
-
VALUES (?, ?, ?)`
|
|
1808
|
-
).run(questId, Number(row.tag_id), effectiveNow);
|
|
1809
|
-
}
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
// src/repeatable_quests/spawn_due_repeatable_quests.ts
|
|
1813
|
-
function spawnDueRepeatableQuests(db, now) {
|
|
1814
|
-
return withTransaction(db, () => {
|
|
1815
|
-
const anchors = listDueRepeatableQuestAnchors(db, now);
|
|
1816
|
-
const quests = [];
|
|
1817
|
-
for (const anchor of anchors) {
|
|
1818
|
-
const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
|
|
1819
|
-
const created = db.prepare(
|
|
1820
|
-
`INSERT INTO quests (
|
|
1821
|
-
questline_id,
|
|
1822
|
-
source_rumor_id,
|
|
1823
|
-
spawned_by_quest_id,
|
|
1824
|
-
spawned_from_repeatable_id,
|
|
1825
|
-
spawned_for_at,
|
|
1826
|
-
title,
|
|
1827
|
-
objective,
|
|
1828
|
-
created_at,
|
|
1829
|
-
updated_at,
|
|
1830
|
-
effort_seconds,
|
|
1831
|
-
estimate_seconds,
|
|
1832
|
-
not_before_at,
|
|
1833
|
-
due_at,
|
|
1834
|
-
scheduled_start_at,
|
|
1835
|
-
scheduled_end_at,
|
|
1836
|
-
all_day
|
|
1837
|
-
) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
|
|
1838
|
-
).run(
|
|
1839
|
-
repeatable.questlineId ?? null,
|
|
1840
|
-
repeatable.id,
|
|
1841
|
-
anchor.anchorAt,
|
|
1842
|
-
repeatable.title,
|
|
1843
|
-
repeatable.objective,
|
|
1844
|
-
now,
|
|
1845
|
-
now,
|
|
1846
|
-
repeatable.estimateSeconds ?? null,
|
|
1847
|
-
repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
|
|
1848
|
-
repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
|
|
1849
|
-
repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
|
|
1850
|
-
repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
|
|
1851
|
-
repeatable.allDay ? 1 : 0
|
|
1852
|
-
);
|
|
1853
|
-
const questId = Number(created.lastInsertRowid);
|
|
1854
|
-
copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
|
|
1855
|
-
copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
|
|
1856
|
-
quests.push(getQuestOrThrow(db, questId));
|
|
1857
|
-
}
|
|
1858
|
-
return quests;
|
|
1859
|
-
});
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
2311
|
// src/rumors/derive_rumor_state.ts
|
|
1863
2312
|
function deriveRumorState(rumor) {
|
|
1864
2313
|
if (rumor.dismissedAt != null) {
|
|
@@ -2864,8 +3313,16 @@ Failure handling:
|
|
|
2864
3313
|
- Do not filter by tags and assume that is the same as a schedule or dependency review.`
|
|
2865
3314
|
});
|
|
2866
3315
|
|
|
3316
|
+
// src/skills/webhooks.ts
|
|
3317
|
+
var webhooksSkill = defineQuestlogSkill({
|
|
3318
|
+
name: "webhooks",
|
|
3319
|
+
description: "Configure signed outbound event webhooks and verify deliveries.",
|
|
3320
|
+
content: '# Webhooks\n\nUse `questlog` to configure opt-in outbound automation. No server or scheduler is added.\nNo targets means no network calls. `manage_hooks` supports list, remove, tick with\n`dry_run`, and deliveries; add/test are CLI-only. `review_questlog` view `deliveries`\nand `questlog show <id>` expose pending/failed deliveries.\n\n```sh\nquestlog hook add https://example.org/wake --min-gap 60\nquestlog hook add https://example.org/team --tag work --events quest.due,quest.overdue --secret-stdin\nquestlog hook label work\nquestlog hook list\nquestlog tick --dry-run\nquestlog tick\nquestlog watch --interval 60\nquestlog hook test 1\nquestlog review deliveries\nquestlog show 12\nquestlog hook remove 1\n```\n\n`hook add` prints its random 32-byte signing secret once, also as `secret` in `--json`.\nSave it securely. `--secret-stdin` supplies one via stdin; there is no secret flag.\nSecrets and custom header values are stored in the DB. New POSIX DB/WAL/SHM files use 0600 and created directories use 0700; existing DB modes are preserved. Windows uses existing access controls.\nUse repeatable `--header "Name: value"` or `--header-stdin` (one line per header, avoiding shell history). Headers are sent on every delivery and test; output shows names with `***` values. HTTP token names are required; host, content-length, content-type, and x-questlog-* are reserved. Change headers by removing and adding the hook again. Use --header-stdin and --secret-stdin separately. URL userinfo is rejected; use a custom header or query key.\n`questlog show d_\u2026` displays delivery status, attempts, HTTP status, next attempt, redacted target URL and event ids. Text is the default; --json prints JSON. Missing hook remove/test targets exit 3; missing quests/deliveries exit 1. hook label names the database.\nList output redacts userinfo/query strings. Removal deletes the target and deliveries.\nDefault scope is the database; `--tag` matches quest tags, never rumors.\n`--events` defaults to all types. The DB label defaults to the filename without its\npath/extension (`questlog` in memory); set it to a name suitable for disclosure.\n\n```cron\n* * * * * questlog tick\n```\n\nCron\'s PATH may need `$HOME/.local/bin/questlog tick --db /path/to/quests.db`.\nWatch defaults to 60 seconds (minimum 5); SIGINT/SIGTERM finishes the current tick.\nTick exits 0 even when deliveries need retries. Dry-run records nothing and sends nothing.\n\nEvents are detected from the model at tick time:\n\n- `quest.due`: an unresolved, undeleted quest reaches its effective due time (own `due_at`, otherwise its undeleted questline\'s due time).\n- `quest.overdue`: that effective deadline is strictly earlier than now and the quest remains unresolved.\n- `quest.unblocked`: an unresolved quest is available, all active prerequisites have succeeded or been deleted, and at least one prerequisite succeeded after its unlock was installed. Occurrence time is the latest active prerequisite success or not-before time, whichever is later.\n- `repeatable.spawned`: a concrete repeatable instance was created; occurrence time is its creation time. Tick first materializes due anchors through the existing spawn operation.\n- `rumor.captured`: an undeleted rumor was created; occurrence time is its creation time, even if subsequently settled or dismissed.\n\nPersistent event keys contain type, entity ids, and occurrence time. Restarts and repeated\nticks do not repeat occurrences. Removed transitions between ticks cannot be reconstructed.\nOnly occurrences at/after target creation are assigned; tags are evaluated at assignment.\nTick materializes due repeatables using the existing spawn path, including during preview\nwith rollback. Batches contain only ids, event types, timestamps, and the DB label:\n\n```json\n{"type":"questlog.events","db":"work","deliveryId":"d_\u2026","sentAt":"2026-10-28T10:00:00.000Z","events":[{"id":"e_\u2026","type":"quest.due","questId":12,"occurredAt":"2026-10-28T09:59:00.000Z"}]}\n```\n\nHeaders are `content-type: application/json`, `user-agent: questlog/<version>`,\n`x-questlog-delivery: <deliveryId>`, `x-questlog-timestamp: <unix seconds>`, and\n`x-questlog-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.\nNo titles, objectives, details, or tag text are sent. `hook test` sends a logged `hook.test`\nevent immediately, ignoring the min gap. Add and test are CLI-only in the tool facade;\n`manage_hooks` supports list, remove, tick (with `dry_run`), and deliveries.\n\n2xx delivers. Timeout is 5 seconds. Failures retry after 30 s, 1 m, 2 m, 4 m, doubling\nto 1 h plus <1 s jitter, subject to the target\'s min gap. Eight failures mark failed.\nRetries keep the delivery id/events; new events queue behind them. Keep the last 500\ndelivered log entries per target and all pending/failed entries. Transactional claims\nlease work for two minutes. A crash after receipt may retry: dedupe at the receiver.\n\n### Verifying a delivery\n\nCompute HMAC-SHA256 over `timestamp + "." + rawBody` with the secret and compare in constant\ntime. Reject timestamps older than 5 minutes (and timestamps too far in the future), then\ndedupe on `x-questlog-delivery`. Use durable deduplication storage in a production receiver.\nThis 15-line Node example uses an in-memory set:\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\nimport { createServer } from \'node:http\';\nconst secret = process.env.WEBHOOK_SECRET, seen = new Set();\ncreateServer(async (req, res) => {\n const chunks = []; for await (const chunk of req) chunks.push(chunk);\n const rawBody = Buffer.concat(chunks), h = req.headers;\n const timestamp = h[\'x-questlog-timestamp\'], id = h[\'x-questlog-delivery\'];\n const age = Date.now() / 1000 - Number(timestamp);\n const expected = createHmac(\'sha256\', secret).update(timestamp + \'.\').update(rawBody).digest();\n const signature = String(h[\'x-questlog-signature\'] || \'\');\n const actual = Buffer.from(signature.slice(7), \'hex\');\n if (!id || !/^\\d+$/.test(timestamp) || age > 300 || age < -300 || !/^sha256=[a-f0-9]{64}$/.test(signature) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { res.writeHead(401).end(); return; }\n if (!seen.has(id)) { seen.add(id); console.log(JSON.parse(rawBody)); }\n res.writeHead(204).end();\n}).listen(8080, \'127.0.0.1\');\n```\n'
|
|
3321
|
+
});
|
|
3322
|
+
|
|
2867
3323
|
// src/skills/skill_registry.ts
|
|
2868
3324
|
var questlogSkills = [
|
|
3325
|
+
webhooksSkill,
|
|
2869
3326
|
archiveDeleteAndPruneSkill,
|
|
2870
3327
|
chooseWorkShapeSkill,
|
|
2871
3328
|
clarifyAmbiguousRequestsSkill,
|
|
@@ -2971,6 +3428,8 @@ __export(tools_exports, {
|
|
|
2971
3428
|
integerSchema: () => integerSchema,
|
|
2972
3429
|
listQuestlogToolDefinitions: () => listQuestlogToolDefinitions,
|
|
2973
3430
|
literalSchema: () => literalSchema,
|
|
3431
|
+
manageHooksTool: () => manageHooksTool,
|
|
3432
|
+
manageHooksToolName: () => manageHooksToolName,
|
|
2974
3433
|
manageRepeatableTool: () => manageRepeatableTool,
|
|
2975
3434
|
manageRepeatableToolHandler: () => manageRepeatableToolHandler,
|
|
2976
3435
|
manageRepeatableToolName: () => manageRepeatableToolName,
|
|
@@ -3027,6 +3486,7 @@ var manageRepeatableToolName = "manage_repeatable";
|
|
|
3027
3486
|
var tagWorkToolName = "tag_work";
|
|
3028
3487
|
var rewardWorkToolName = "reward_work";
|
|
3029
3488
|
var retireWorkToolName = "retire_work";
|
|
3489
|
+
var manageHooksToolName = "manage_hooks";
|
|
3030
3490
|
|
|
3031
3491
|
// src/tools/tool_types.ts
|
|
3032
3492
|
function withOptionalFields(result, options) {
|
|
@@ -3282,6 +3742,7 @@ function validateInput(schema, input, path = "$") {
|
|
|
3282
3742
|
|
|
3283
3743
|
// src/tools/action_requirements.ts
|
|
3284
3744
|
var actionRequirements = {
|
|
3745
|
+
manage_hooks: { remove: ["id"] },
|
|
3285
3746
|
shape_work: {
|
|
3286
3747
|
create_quest: ["quest"],
|
|
3287
3748
|
create_questline: ["questline"],
|
|
@@ -3664,7 +4125,8 @@ function inspectQuestlogItemToolHandler(db, input) {
|
|
|
3664
4125
|
detail: detailLevel === "full" ? detail : toQuestCompactDetail(detail)
|
|
3665
4126
|
},
|
|
3666
4127
|
related,
|
|
3667
|
-
detailLevel
|
|
4128
|
+
detailLevel,
|
|
4129
|
+
deliveries: listDeliveries(db, { questId: detail.id })
|
|
3668
4130
|
},
|
|
3669
4131
|
{ entities }
|
|
3670
4132
|
);
|
|
@@ -3770,6 +4232,56 @@ var inspectQuestlogItemTool = defineQuestlogTool({
|
|
|
3770
4232
|
handler: inspectQuestlogItemToolHandler
|
|
3771
4233
|
});
|
|
3772
4234
|
|
|
4235
|
+
// src/tools/manage_hooks_tool.ts
|
|
4236
|
+
var manageHooksTool = defineQuestlogTool({
|
|
4237
|
+
name: "manage_hooks",
|
|
4238
|
+
description: "List or remove outbound webhook targets, run a tick, or inspect pending and failed deliveries.",
|
|
4239
|
+
whenToUse: "Use to operate configured outbound webhooks. Add and test targets with the CLI.",
|
|
4240
|
+
whenNotToUse: "Use quest tools to change work itself.",
|
|
4241
|
+
sideEffects: "writes_state",
|
|
4242
|
+
readOnly: false,
|
|
4243
|
+
supportsClarification: false,
|
|
4244
|
+
targetKinds: ["hook"],
|
|
4245
|
+
inputDescriptions: {
|
|
4246
|
+
action: "Operation to perform.",
|
|
4247
|
+
id: "Target id for removal.",
|
|
4248
|
+
dry_run: "Preview a tick without recording or sending."
|
|
4249
|
+
},
|
|
4250
|
+
outputDescription: "Returns redacted targets, delivery metadata, or a tick summary. Never returns signing secrets.",
|
|
4251
|
+
inputSchema: objectSchema(
|
|
4252
|
+
{
|
|
4253
|
+
action: enumSchema("Operation.", ["list", "remove", "tick", "deliveries"]),
|
|
4254
|
+
id: integerSchema("Target id."),
|
|
4255
|
+
dry_run: booleanSchema("Preview only.")
|
|
4256
|
+
},
|
|
4257
|
+
["action"]
|
|
4258
|
+
),
|
|
4259
|
+
handler: async (db, input) => {
|
|
4260
|
+
switch (input.action) {
|
|
4261
|
+
case "list":
|
|
4262
|
+
return toolSuccess("Webhook targets.", listHooks(db));
|
|
4263
|
+
case "deliveries":
|
|
4264
|
+
return toolSuccess("Pending and failed deliveries.", listDeliveries(db));
|
|
4265
|
+
case "remove":
|
|
4266
|
+
if (!Number.isSafeInteger(input.id) || Number(input.id) < 1)
|
|
4267
|
+
return toolFailure(
|
|
4268
|
+
"protocol",
|
|
4269
|
+
"invalid_input",
|
|
4270
|
+
"Target id required.",
|
|
4271
|
+
"Provide a positive target id.",
|
|
4272
|
+
{ recovery: 'Pass {"action":"remove","id":1}' }
|
|
4273
|
+
);
|
|
4274
|
+
removeHook(db, Number(input.id));
|
|
4275
|
+
return toolSuccess("Webhook target removed.", {});
|
|
4276
|
+
case "tick":
|
|
4277
|
+
return toolSuccess(
|
|
4278
|
+
"Webhook tick completed.",
|
|
4279
|
+
await tickQuestlog(db, { dryRun: input.dry_run ?? false })
|
|
4280
|
+
);
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
});
|
|
4284
|
+
|
|
3773
4285
|
// src/tools/manage_repeatable_tool.ts
|
|
3774
4286
|
function manageRepeatableToolHandler(db, input) {
|
|
3775
4287
|
try {
|
|
@@ -4469,7 +4981,7 @@ function validateReviewInput(input) {
|
|
|
4469
4981
|
'Use `filters.dayAt` only with `view: "quests.scheduled_for_day"`.'
|
|
4470
4982
|
);
|
|
4471
4983
|
}
|
|
4472
|
-
if ((input.view === "rumors" || input.view === "questlines") && hasQuestFilters(input.filters)) {
|
|
4984
|
+
if ((input.view === "rumors" || input.view === "questlines" || input.view === "deliveries") && hasQuestFilters(input.filters)) {
|
|
4473
4985
|
return toolFailure(
|
|
4474
4986
|
"protocol",
|
|
4475
4987
|
"invalid_input",
|
|
@@ -4537,6 +5049,15 @@ function reviewQuestlogToolHandler(db, input) {
|
|
|
4537
5049
|
if (invalid) {
|
|
4538
5050
|
return invalid;
|
|
4539
5051
|
}
|
|
5052
|
+
if (input.view === "deliveries")
|
|
5053
|
+
return toolSuccess("Pending and failed deliveries.", {
|
|
5054
|
+
view: input.view,
|
|
5055
|
+
items: [],
|
|
5056
|
+
deliveries: listDeliveries(db),
|
|
5057
|
+
count: listDeliveries(db).length,
|
|
5058
|
+
appliedFilters: input.filters,
|
|
5059
|
+
evaluatedAt: input.now ?? Date.now()
|
|
5060
|
+
});
|
|
4540
5061
|
const now = input.now ?? Date.now();
|
|
4541
5062
|
let items = [];
|
|
4542
5063
|
switch (input.view) {
|
|
@@ -4596,6 +5117,7 @@ var reviewQuestlogTool = defineQuestlogTool({
|
|
|
4596
5117
|
inputSchema: objectSchema(
|
|
4597
5118
|
{
|
|
4598
5119
|
view: enumSchema("Which operational review surface to return.", [
|
|
5120
|
+
"deliveries",
|
|
4599
5121
|
"questlines",
|
|
4600
5122
|
"quests.active",
|
|
4601
5123
|
"quests.available",
|
|
@@ -5797,6 +6319,7 @@ var tagWorkTool = defineQuestlogTool({
|
|
|
5797
6319
|
|
|
5798
6320
|
// src/tools/tool_mapping.ts
|
|
5799
6321
|
var questlogToolMappings = [
|
|
6322
|
+
{ source: "tickQuestlog", tool: "manage_hooks", action: "tick" },
|
|
5800
6323
|
{ source: "searchQuestlog", tool: "search_questlog" },
|
|
5801
6324
|
{ source: "listRumors", tool: "review_questlog", action: "rumors" },
|
|
5802
6325
|
{ source: "listQuestlines", tool: "review_questlog", action: "questlines" },
|
|
@@ -5894,6 +6417,7 @@ var questlogToolMappings = [
|
|
|
5894
6417
|
|
|
5895
6418
|
// src/tools/tool_registry.ts
|
|
5896
6419
|
var questlogTools = [
|
|
6420
|
+
manageHooksTool,
|
|
5897
6421
|
searchQuestlogTool,
|
|
5898
6422
|
reviewQuestlogTool,
|
|
5899
6423
|
inspectQuestlogItemTool,
|
|
@@ -5957,6 +6481,7 @@ __export(write_exports, {
|
|
|
5957
6481
|
updateRumor: () => updateRumor
|
|
5958
6482
|
});
|
|
5959
6483
|
|
|
6484
|
+
exports.hooks = hooks_exports;
|
|
5960
6485
|
exports.initQuestlogTables = initQuestlogTables;
|
|
5961
6486
|
exports.markers = markers_exports;
|
|
5962
6487
|
exports.read = read_exports;
|