@ghostpaw/questlog 0.9.1 → 0.10.1

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/dist/index.js CHANGED
@@ -1,496 +1,131 @@
1
+ import { randomBytes, createHmac } from 'node:crypto';
2
+ import { basename, extname } from 'node:path';
3
+
1
4
  var __defProp = Object.defineProperty;
2
5
  var __export = (target, all) => {
3
6
  for (var name in all)
4
7
  __defProp(target, name, { get: all[name], enumerable: true });
5
8
  };
6
9
 
7
- // src/questlines/init_questline_tables.ts
8
- function initQuestlineTables(db) {
9
- db.exec(`
10
- CREATE TABLE IF NOT EXISTS questlines (
11
- id INTEGER PRIMARY KEY,
12
- source_rumor_id INTEGER REFERENCES rumors(id),
13
- title TEXT NOT NULL,
14
- description TEXT,
15
- starts_at INTEGER,
16
- due_at INTEGER,
17
- created_at INTEGER NOT NULL,
18
- updated_at INTEGER NOT NULL,
19
- archived_at INTEGER,
20
- deleted_at INTEGER,
21
- CHECK(length(trim(title)) > 0),
22
- CHECK(due_at IS NULL OR starts_at IS NULL OR due_at >= starts_at)
23
- )
24
- `);
25
- db.exec("CREATE INDEX IF NOT EXISTS idx_questlines_due_at ON questlines(due_at)");
26
- db.exec("CREATE INDEX IF NOT EXISTS idx_questlines_deleted_at ON questlines(deleted_at)");
27
- db.exec(
28
- "CREATE INDEX IF NOT EXISTS idx_questlines_source_rumor_id ON questlines(source_rumor_id) WHERE deleted_at IS NULL"
29
- );
30
- db.exec(
31
- "CREATE INDEX IF NOT EXISTS idx_questlines_created_at_active ON questlines(created_at DESC, id DESC) WHERE deleted_at IS NULL"
32
- );
33
- }
10
+ // src/hooks/index.ts
11
+ var hooks_exports = {};
12
+ __export(hooks_exports, {
13
+ addHook: () => addHook,
14
+ hookEventTypes: () => hookEventTypes,
15
+ hookLabel: () => hookLabel,
16
+ listDeliveries: () => listDeliveries,
17
+ listHooks: () => listHooks,
18
+ removeHook: () => removeHook,
19
+ testHook: () => testHook,
20
+ tickQuestlog: () => tickQuestlog,
21
+ watchQuestlog: () => watchQuestlog
22
+ });
34
23
 
35
- // src/quests/init_quest_search.ts
36
- function initQuestSearch(db) {
37
- db.exec(`
38
- CREATE VIRTUAL TABLE IF NOT EXISTS quests_fts USING fts5(
39
- title,
40
- objective,
41
- outcome,
42
- content=quests,
43
- content_rowid=id
44
- )
45
- `);
46
- db.exec(`
47
- CREATE TRIGGER IF NOT EXISTS quests_fts_insert
48
- AFTER INSERT ON quests
49
- WHEN new.deleted_at IS NULL
50
- BEGIN
51
- INSERT INTO quests_fts(rowid, title, objective, outcome)
52
- VALUES (new.id, new.title, new.objective, COALESCE(new.outcome, ''));
53
- END
54
- `);
55
- db.exec(`
56
- CREATE TRIGGER IF NOT EXISTS quests_fts_update
57
- AFTER UPDATE OF title, objective, outcome, deleted_at ON quests
58
- BEGIN
59
- INSERT INTO quests_fts(quests_fts, rowid, title, objective, outcome)
60
- VALUES ('delete', old.id, old.title, old.objective, COALESCE(old.outcome, ''));
61
- INSERT INTO quests_fts(rowid, title, objective, outcome)
62
- SELECT new.id, new.title, new.objective, COALESCE(new.outcome, '')
63
- WHERE new.deleted_at IS NULL;
64
- END
65
- `);
24
+ // src/tags/normalize_tag_name.ts
25
+ function normalizeTagName(name) {
26
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
66
27
  }
67
28
 
68
- // src/quests/init_quest_tables.ts
69
- function initQuestTables(db) {
70
- db.exec(`
71
- CREATE TABLE IF NOT EXISTS quests (
72
- id INTEGER PRIMARY KEY,
73
- questline_id INTEGER REFERENCES questlines(id),
74
- source_rumor_id INTEGER REFERENCES rumors(id),
75
- spawned_by_quest_id INTEGER REFERENCES quests(id),
76
- spawned_from_repeatable_id INTEGER REFERENCES repeatable_quests(id),
77
- spawned_for_at INTEGER,
78
- title TEXT NOT NULL,
79
- objective TEXT NOT NULL,
80
- outcome TEXT,
81
- created_at INTEGER NOT NULL,
82
- updated_at INTEGER NOT NULL,
83
- started_at INTEGER,
84
- resolved_at INTEGER,
85
- success INTEGER CHECK(success IN (0, 1) OR success IS NULL),
86
- effort_seconds INTEGER NOT NULL DEFAULT 0 CHECK(effort_seconds >= 0),
87
- estimate_seconds INTEGER CHECK(estimate_seconds IS NULL OR estimate_seconds >= 0),
88
- not_before_at INTEGER,
89
- due_at INTEGER,
90
- scheduled_start_at INTEGER,
91
- scheduled_end_at INTEGER,
92
- all_day INTEGER NOT NULL DEFAULT 0 CHECK(all_day IN (0, 1)),
93
- deleted_at INTEGER,
94
- CHECK(length(trim(title)) > 0),
95
- CHECK(length(trim(objective)) > 0),
96
- CHECK(
97
- (resolved_at IS NULL AND success IS NULL AND outcome IS NULL)
98
- OR (resolved_at IS NOT NULL AND success IS NOT NULL AND outcome IS NOT NULL)
99
- ),
100
- CHECK(started_at IS NULL OR started_at >= created_at),
101
- CHECK(resolved_at IS NULL OR resolved_at >= created_at),
102
- CHECK(resolved_at IS NULL OR started_at IS NULL OR resolved_at >= started_at),
103
- CHECK(scheduled_end_at IS NULL OR scheduled_start_at IS NOT NULL),
104
- CHECK(scheduled_end_at IS NULL OR scheduled_end_at >= scheduled_start_at),
105
- CHECK(not_before_at IS NULL OR due_at IS NULL OR not_before_at <= due_at),
106
- CHECK(
107
- (spawned_from_repeatable_id IS NULL AND spawned_for_at IS NULL)
108
- OR (spawned_from_repeatable_id IS NOT NULL AND spawned_for_at IS NOT NULL)
109
- )
110
- )
111
- `);
112
- db.exec("CREATE INDEX IF NOT EXISTS idx_quests_deleted_at ON quests(deleted_at)");
113
- db.exec("CREATE INDEX IF NOT EXISTS idx_quests_questline_id ON quests(questline_id)");
114
- db.exec("CREATE INDEX IF NOT EXISTS idx_quests_source_rumor_id ON quests(source_rumor_id)");
115
- db.exec(
116
- "CREATE INDEX IF NOT EXISTS idx_quests_spawned_by_quest_id ON quests(spawned_by_quest_id)"
117
- );
118
- db.exec(
119
- "CREATE INDEX IF NOT EXISTS idx_quests_repeatable_anchor ON quests(spawned_from_repeatable_id, spawned_for_at)"
120
- );
121
- db.exec(
122
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_quests_repeatable_anchor_unique ON quests(spawned_from_repeatable_id, spawned_for_at) WHERE spawned_from_repeatable_id IS NOT NULL AND spawned_for_at IS NOT NULL AND deleted_at IS NULL"
123
- );
124
- db.exec(
125
- "CREATE INDEX IF NOT EXISTS idx_quests_due_at ON quests(due_at) WHERE deleted_at IS NULL"
126
- );
127
- db.exec(
128
- "CREATE INDEX IF NOT EXISTS idx_quests_not_before_at ON quests(not_before_at) WHERE deleted_at IS NULL"
129
- );
130
- db.exec(
131
- "CREATE INDEX IF NOT EXISTS idx_quests_scheduled_window ON quests(scheduled_start_at, scheduled_end_at) WHERE deleted_at IS NULL"
132
- );
133
- db.exec(
134
- "CREATE INDEX IF NOT EXISTS idx_quests_lifecycle ON quests(started_at, resolved_at, success) WHERE deleted_at IS NULL"
135
- );
136
- db.exec(
137
- "CREATE INDEX IF NOT EXISTS idx_quests_created_at_active ON quests(created_at DESC, id DESC) WHERE deleted_at IS NULL"
138
- );
139
- }
29
+ // src/hooks/types.ts
30
+ var hookEventTypes = [
31
+ "quest.due",
32
+ "quest.overdue",
33
+ "quest.unblocked",
34
+ "repeatable.spawned",
35
+ "rumor.captured"
36
+ ];
140
37
 
141
- // src/repeatable_quests/init_repeatable_quest_tables.ts
142
- function initRepeatableQuestTables(db) {
143
- db.exec(`
144
- CREATE TABLE IF NOT EXISTS repeatable_quests (
145
- id INTEGER PRIMARY KEY,
146
- questline_id INTEGER REFERENCES questlines(id),
147
- title TEXT NOT NULL,
148
- objective TEXT NOT NULL,
149
- rrule TEXT NOT NULL,
150
- anchor_at INTEGER NOT NULL,
151
- not_before_offset_seconds INTEGER,
152
- due_offset_seconds INTEGER,
153
- scheduled_start_offset_seconds INTEGER,
154
- scheduled_end_offset_seconds INTEGER,
155
- all_day INTEGER NOT NULL DEFAULT 0 CHECK(all_day IN (0, 1)),
156
- estimate_seconds INTEGER CHECK(estimate_seconds IS NULL OR estimate_seconds >= 0),
157
- created_at INTEGER NOT NULL,
158
- updated_at INTEGER NOT NULL,
159
- archived_at INTEGER,
160
- deleted_at INTEGER,
161
- CHECK(length(trim(title)) > 0),
162
- CHECK(length(trim(objective)) > 0),
163
- CHECK(scheduled_end_offset_seconds IS NULL OR scheduled_start_offset_seconds IS NOT NULL),
164
- CHECK(
165
- scheduled_end_offset_seconds IS NULL
166
- OR scheduled_end_offset_seconds >= scheduled_start_offset_seconds
167
- )
168
- )
169
- `);
170
- db.exec(
171
- "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_deleted_at ON repeatable_quests(deleted_at)"
172
- );
173
- db.exec(
174
- "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_archived_at ON repeatable_quests(archived_at)"
38
+ // src/hooks/add_hook.ts
39
+ function addHook(db, input) {
40
+ let url;
41
+ try {
42
+ url = new URL(input.url);
43
+ } catch {
44
+ throw new Error("Expected an HTTP or HTTPS URL");
45
+ }
46
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error("Expected an HTTP or HTTPS URL");
47
+ const minGap = input.minGap ?? 60;
48
+ if (!Number.isSafeInteger(minGap) || minGap < 0)
49
+ throw new Error("Min gap must be a nonnegative integer");
50
+ const events = input.events ?? [...hookEventTypes];
51
+ if (!events.length || events.some((e) => !hookEventTypes.includes(e)))
52
+ throw new Error("Unknown event filter");
53
+ if (input.tag !== void 0 && !normalizeTagName(input.tag))
54
+ throw new Error("Expected a tag name");
55
+ const secret = input.secret ?? randomBytes(32).toString("hex");
56
+ if (!secret.trim()) throw new Error("Expected a nonempty signing secret");
57
+ const result = db.prepare(
58
+ "INSERT INTO hook_targets(url, secret, tag, events, min_gap, created_at) VALUES (?, ?, ?, ?, ?, ?)"
59
+ ).run(
60
+ url.href,
61
+ secret,
62
+ input.tag === void 0 ? null : normalizeTagName(input.tag),
63
+ JSON.stringify(events),
64
+ minGap,
65
+ input.now ?? Date.now()
175
66
  );
176
- db.exec(
177
- "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_active_anchor ON repeatable_quests(anchor_at) WHERE deleted_at IS NULL AND archived_at IS NULL"
67
+ return { id: Number(result.lastInsertRowid), secret };
68
+ }
69
+ function hookLabel(db, name) {
70
+ if (name !== void 0) {
71
+ if (!name.trim()) throw new Error("Expected a nonempty database label");
72
+ db.prepare(
73
+ "INSERT INTO hook_settings(id, label) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET label = excluded.label"
74
+ ).run(name);
75
+ }
76
+ const saved = db.prepare("SELECT label FROM hook_settings WHERE id = 1").get();
77
+ if (saved) return String(saved.label);
78
+ const path = String(
79
+ db.prepare("PRAGMA database_list").all().find((row) => row.name === "main")?.file ?? ""
178
80
  );
81
+ return path ? basename(path, extname(path)) : "questlog";
179
82
  }
180
83
 
181
- // src/rewards/init_reward_tables.ts
182
- function initRewardTables(db) {
183
- db.exec(`
184
- CREATE TABLE IF NOT EXISTS quest_rewards (
185
- id INTEGER PRIMARY KEY,
186
- quest_id INTEGER NOT NULL REFERENCES quests(id),
187
- kind TEXT NOT NULL,
188
- name TEXT NOT NULL,
189
- description TEXT,
190
- quantity REAL CHECK(quantity IS NULL OR quantity >= 0),
191
- created_at INTEGER NOT NULL,
192
- claimed_at INTEGER,
193
- deleted_at INTEGER,
194
- CHECK(length(trim(kind)) > 0),
195
- CHECK(length(trim(name)) > 0)
196
- )
197
- `);
198
- db.exec(
199
- "CREATE INDEX IF NOT EXISTS idx_quest_rewards_quest_id ON quest_rewards(quest_id) WHERE deleted_at IS NULL"
200
- );
201
- db.exec(
202
- "CREATE INDEX IF NOT EXISTS idx_quest_rewards_claimed_at ON quest_rewards(claimed_at) WHERE deleted_at IS NULL"
203
- );
204
- db.exec(`
205
- CREATE TABLE IF NOT EXISTS repeatable_quest_rewards (
206
- id INTEGER PRIMARY KEY,
207
- repeatable_quest_id INTEGER NOT NULL REFERENCES repeatable_quests(id),
208
- kind TEXT NOT NULL,
209
- name TEXT NOT NULL,
210
- description TEXT,
211
- quantity REAL CHECK(quantity IS NULL OR quantity >= 0),
212
- created_at INTEGER NOT NULL,
213
- deleted_at INTEGER,
214
- CHECK(length(trim(kind)) > 0),
215
- CHECK(length(trim(name)) > 0)
216
- )
217
- `);
218
- db.exec(
219
- "CREATE INDEX IF NOT EXISTS idx_repeatable_quest_rewards_repeatable_quest_id ON repeatable_quest_rewards(repeatable_quest_id) WHERE deleted_at IS NULL"
84
+ // src/hooks/list_deliveries.ts
85
+ function listDeliveries(db, options = {}) {
86
+ return db.prepare(
87
+ `SELECT * FROM hook_deliveries ${options.includeDelivered ? "" : "WHERE status <> 'delivered'"} ORDER BY target_id, next_attempt_at, id`
88
+ ).all().map(
89
+ (row) => ({
90
+ id: String(row.id),
91
+ targetId: Number(row.target_id),
92
+ events: JSON.parse(String(row.events)),
93
+ status: row.status,
94
+ attempts: Number(row.attempts),
95
+ lastAttemptAt: row.last_attempt_at == null ? null : Number(row.last_attempt_at),
96
+ httpStatus: row.http_status == null ? null : Number(row.http_status),
97
+ errorClass: row.error_class == null ? null : String(row.error_class),
98
+ nextAttemptAt: Number(row.next_attempt_at)
99
+ })
100
+ ).filter(
101
+ (d) => options.questId === void 0 || d.events.some((e) => e.questId === options.questId)
220
102
  );
221
103
  }
222
104
 
223
- // src/rumors/init_rumor_search.ts
224
- function initRumorSearch(db) {
225
- db.exec(`
226
- CREATE VIRTUAL TABLE IF NOT EXISTS rumors_fts USING fts5(
227
- title,
228
- details,
229
- content=rumors,
230
- content_rowid=id
231
- )
232
- `);
233
- db.exec(`
234
- CREATE TRIGGER IF NOT EXISTS rumors_fts_insert
235
- AFTER INSERT ON rumors
236
- WHEN new.deleted_at IS NULL
237
- BEGIN
238
- INSERT INTO rumors_fts(rowid, title, details)
239
- VALUES (new.id, new.title, COALESCE(new.details, ''));
240
- END
241
- `);
242
- db.exec(`
243
- CREATE TRIGGER IF NOT EXISTS rumors_fts_update
244
- AFTER UPDATE OF title, details, deleted_at ON rumors
245
- BEGIN
246
- INSERT INTO rumors_fts(rumors_fts, rowid, title, details)
247
- VALUES ('delete', old.id, old.title, COALESCE(old.details, ''));
248
- INSERT INTO rumors_fts(rowid, title, details)
249
- SELECT new.id, new.title, COALESCE(new.details, '')
250
- WHERE new.deleted_at IS NULL;
251
- END
252
- `);
253
- }
254
-
255
- // src/rumors/init_rumor_tables.ts
256
- function initRumorTables(db) {
257
- db.exec(`
258
- CREATE TABLE IF NOT EXISTS rumors (
259
- id INTEGER PRIMARY KEY,
260
- title TEXT NOT NULL,
261
- details TEXT,
262
- created_at INTEGER NOT NULL,
263
- updated_at INTEGER NOT NULL,
264
- settled_at INTEGER,
265
- dismissed_at INTEGER,
266
- deleted_at INTEGER,
267
- CHECK(length(trim(title)) > 0),
268
- CHECK(settled_at IS NULL OR dismissed_at IS NULL)
269
- )
270
- `);
271
- db.exec(
272
- "CREATE INDEX IF NOT EXISTS idx_rumors_open ON rumors(settled_at, dismissed_at, deleted_at)"
273
- );
274
- db.exec(
275
- "CREATE INDEX IF NOT EXISTS idx_rumors_created_at_active ON rumors(created_at DESC, id DESC) WHERE deleted_at IS NULL"
276
- );
277
- }
278
-
279
- // src/tags/init_tag_tables.ts
280
- function initTagTables(db) {
281
- db.exec(`
282
- CREATE TABLE IF NOT EXISTS tags (
283
- id INTEGER PRIMARY KEY,
284
- name TEXT NOT NULL,
285
- normalized_name TEXT NOT NULL,
286
- created_at INTEGER NOT NULL,
287
- deleted_at INTEGER,
288
- CHECK(length(trim(name)) > 0),
289
- CHECK(length(trim(normalized_name)) > 0)
290
- )
291
- `);
292
- db.exec(
293
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_normalized_name_unique ON tags(normalized_name) WHERE deleted_at IS NULL"
294
- );
295
- db.exec(`
296
- CREATE TABLE IF NOT EXISTS quest_tags (
297
- quest_id INTEGER NOT NULL REFERENCES quests(id),
298
- tag_id INTEGER NOT NULL REFERENCES tags(id),
299
- created_at INTEGER NOT NULL,
300
- deleted_at INTEGER,
301
- PRIMARY KEY (quest_id, tag_id, created_at)
302
- )
303
- `);
304
- db.exec(
305
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_quest_tags_unique ON quest_tags(quest_id, tag_id) WHERE deleted_at IS NULL"
306
- );
307
- db.exec(
308
- "CREATE INDEX IF NOT EXISTS idx_quest_tags_tag_id ON quest_tags(tag_id) WHERE deleted_at IS NULL"
309
- );
310
- db.exec(`
311
- CREATE TABLE IF NOT EXISTS repeatable_quest_tags (
312
- repeatable_quest_id INTEGER NOT NULL REFERENCES repeatable_quests(id),
313
- tag_id INTEGER NOT NULL REFERENCES tags(id),
314
- created_at INTEGER NOT NULL,
315
- deleted_at INTEGER,
316
- PRIMARY KEY (repeatable_quest_id, tag_id, created_at)
317
- )
318
- `);
319
- db.exec(
320
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_repeatable_quest_tags_unique ON repeatable_quest_tags(repeatable_quest_id, tag_id) WHERE deleted_at IS NULL"
321
- );
322
- }
323
-
324
- // src/unlocks/init_unlock_tables.ts
325
- function initUnlockTables(db) {
326
- db.exec(`
327
- CREATE TABLE IF NOT EXISTS quest_unlocks (
328
- id INTEGER PRIMARY KEY,
329
- from_quest_id INTEGER NOT NULL REFERENCES quests(id),
330
- to_quest_id INTEGER NOT NULL REFERENCES quests(id),
331
- created_at INTEGER NOT NULL,
332
- deleted_at INTEGER,
333
- CHECK(from_quest_id <> to_quest_id)
334
- )
335
- `);
336
- db.exec(
337
- "CREATE UNIQUE INDEX IF NOT EXISTS idx_quest_unlocks_pair_unique ON quest_unlocks(from_quest_id, to_quest_id) WHERE deleted_at IS NULL"
338
- );
339
- db.exec(
340
- "CREATE INDEX IF NOT EXISTS idx_quest_unlocks_to_quest_id ON quest_unlocks(to_quest_id) WHERE deleted_at IS NULL"
341
- );
342
- }
343
-
344
- // src/init_questlog_tables.ts
345
- function initQuestlogTables(db) {
346
- initRumorTables(db);
347
- initQuestlineTables(db);
348
- initRepeatableQuestTables(db);
349
- initQuestTables(db);
350
- initUnlockTables(db);
351
- initRewardTables(db);
352
- initTagTables(db);
353
- initQuestSearch(db);
354
- initRumorSearch(db);
355
- }
356
-
357
- // src/markers/index.ts
358
- var markers_exports = {};
359
- __export(markers_exports, {
360
- deriveQuestMarkerId: () => deriveQuestMarkerId,
361
- deriveRumorMarkerId: () => deriveRumorMarkerId,
362
- markerLookup: () => markerLookup
363
- });
364
-
365
- // src/markers/derive_quest_marker_id.ts
366
- function deriveQuestMarkerId({
367
- state,
368
- available,
369
- hasUnclaimedReward,
370
- notBeforeAt,
371
- blockerCount,
372
- now
373
- }) {
374
- if (state === "done") {
375
- return hasUnclaimedReward ? "progress.complete" : null;
376
- }
377
- if (state === "abandoned") {
378
- return null;
379
- }
380
- if (state === "in_progress") {
381
- return "progress.incomplete";
382
- }
383
- if (available) {
384
- return "attention.available";
385
- }
386
- if (notBeforeAt != null && notBeforeAt > now) {
387
- return "attention.available.future";
388
- }
389
- if (blockerCount > 0) {
390
- return "progress.incomplete";
391
- }
392
- return null;
393
- }
394
-
395
- // src/markers/derive_rumor_marker_id.ts
396
- function deriveRumorMarkerId({ state }) {
397
- if (state === "open") {
398
- return "attention.available";
399
- }
400
- return null;
401
- }
402
-
403
- // src/markers/lookup.ts
404
- var markerLookup = {
405
- "attention.available": {
406
- id: "attention.available",
407
- family: "attention",
408
- symbol: "!",
409
- wowColor: "yellow",
410
- label: "Available",
411
- plainText: "! available",
412
- ttyText: "\x1B[33m!\x1B[39m",
413
- htmlClass: "questlog-marker--attention-available"
414
- },
415
- "attention.available.repeatable": {
416
- id: "attention.available.repeatable",
417
- family: "attention",
418
- symbol: "!",
419
- wowColor: "blue",
420
- label: "Repeatable available",
421
- plainText: "! repeatable",
422
- ttyText: "\x1B[34m!\x1B[39m",
423
- htmlClass: "questlog-marker--attention-available-repeatable"
424
- },
425
- "attention.available.future": {
426
- id: "attention.available.future",
427
- family: "attention",
428
- symbol: "!",
429
- wowColor: "gray",
430
- label: "Future available",
431
- plainText: "! future",
432
- ttyText: "\x1B[90m!\x1B[39m",
433
- htmlClass: "questlog-marker--attention-available-future"
434
- },
435
- "progress.incomplete": {
436
- id: "progress.incomplete",
437
- family: "progress",
438
- symbol: "?",
439
- wowColor: "gray",
440
- label: "Incomplete",
441
- plainText: "? incomplete",
442
- ttyText: "\x1B[90m?\x1B[39m",
443
- htmlClass: "questlog-marker--progress-incomplete"
444
- },
445
- "progress.complete": {
446
- id: "progress.complete",
447
- family: "progress",
448
- symbol: "?",
449
- wowColor: "yellow",
450
- label: "Turn-in pending",
451
- plainText: "? turn-in pending",
452
- ttyText: "\x1B[33m?\x1B[39m",
453
- htmlClass: "questlog-marker--progress-complete"
454
- }
455
- };
456
-
457
- // src/read.ts
458
- var read_exports = {};
459
- __export(read_exports, {
460
- getQuestDetail: () => getQuestDetail,
461
- getQuestlineDetail: () => getQuestlineDetail,
462
- getRumorDetail: () => getRumorDetail,
463
- getRumorOutputs: () => getRumorOutputs,
464
- listActiveQuests: () => listActiveQuests,
465
- listAvailableQuests: () => listAvailableQuests,
466
- listBlockedQuests: () => listBlockedQuests,
467
- listDeferredQuests: () => listDeferredQuests,
468
- listDueRepeatableQuestAnchors: () => listDueRepeatableQuestAnchors,
469
- listDueSoonQuests: () => listDueSoonQuests,
470
- listInProgressQuests: () => listInProgressQuests,
471
- listMissedScheduledQuests: () => listMissedScheduledQuests,
472
- listOpenQuests: () => listOpenQuests,
473
- listOverdueQuests: () => listOverdueQuests,
474
- listQuestlines: () => listQuestlines,
475
- listResolvedQuests: () => listResolvedQuests,
476
- listRumors: () => listRumors,
477
- listScheduledForDay: () => listScheduledForDay,
478
- listScheduledNow: () => listScheduledNow,
479
- searchQuestlog: () => searchQuestlog
480
- });
481
-
482
- // src/resolve_now.ts
483
- function resolveNow(now) {
484
- return now ?? Date.now();
485
- }
486
-
487
- // src/assert_non_empty.ts
488
- function assertNonEmpty(value, label) {
489
- const trimmed = value.trim();
490
- if (trimmed.length === 0) {
491
- throw new Error(`${label} must not be empty.`);
492
- }
493
- return trimmed;
105
+ // src/hooks/list_hooks.ts
106
+ function listHooks(db) {
107
+ return db.prepare(`SELECT id, url, tag, events, min_gap, created_at, last_wake,
108
+ (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,
109
+ (SELECT COUNT(*) FROM hook_deliveries d WHERE d.target_id = t.id AND d.status = 'failed') AS failed
110
+ FROM hook_targets t ORDER BY id`).all().map((row) => {
111
+ const url = new URL(String(row.url));
112
+ url.username = "";
113
+ url.password = "";
114
+ url.search = "";
115
+ url.hash = "";
116
+ return {
117
+ id: Number(row.id),
118
+ scope: row.tag == null ? "database" : "tag",
119
+ url: url.href,
120
+ tag: row.tag == null ? null : String(row.tag),
121
+ events: JSON.parse(String(row.events)),
122
+ minGap: Number(row.min_gap),
123
+ createdAt: Number(row.created_at),
124
+ lastWake: row.last_wake == null ? null : Number(row.last_wake),
125
+ pending: Number(row.pending),
126
+ failed: Number(row.failed)
127
+ };
128
+ });
494
129
  }
495
130
 
496
131
  // src/with_transaction.ts
@@ -528,232 +163,112 @@ function withTransaction(db, fn) {
528
163
  }
529
164
  }
530
165
 
531
- // src/questlines/map_questline_row.ts
532
- function mapQuestlineRow(row) {
533
- return {
534
- id: Number(row.id),
535
- sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
536
- title: String(row.title),
537
- description: row.description == null ? null : String(row.description),
538
- startsAt: row.starts_at == null ? null : Number(row.starts_at),
539
- dueAt: row.due_at == null ? null : Number(row.due_at),
540
- createdAt: Number(row.created_at),
541
- updatedAt: Number(row.updated_at),
542
- archivedAt: row.archived_at == null ? null : Number(row.archived_at),
543
- deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
544
- };
166
+ // src/hooks/remove_hook.ts
167
+ function removeHook(db, id) {
168
+ withTransaction(db, () => {
169
+ db.prepare("DELETE FROM hook_assignments WHERE target_id = ?").run(id);
170
+ db.prepare("DELETE FROM hook_deliveries WHERE target_id = ?").run(id);
171
+ db.prepare("DELETE FROM hook_targets WHERE id = ?").run(id);
172
+ });
545
173
  }
546
174
 
547
- // src/questlines/get_questline_or_throw.ts
548
- function getQuestlineOrThrow(db, questlineId) {
549
- const row = db.prepare("SELECT * FROM questlines WHERE id = ? AND deleted_at IS NULL").get(questlineId);
550
- if (!row) {
551
- throw new Error(`Questline ${questlineId} was not found.`);
552
- }
553
- return mapQuestlineRow(row);
554
- }
175
+ // src/version.ts
176
+ var questlogVersion = "0.10.1";
555
177
 
556
- // src/questlines/update_questline.ts
557
- function updateQuestline(db, questlineId, input) {
558
- return withTransaction(db, () => {
559
- const current = getQuestlineOrThrow(db, questlineId);
560
- const now = resolveNow(input.now);
178
+ // src/hooks/send_hook_delivery.ts
179
+ async function sendHookDelivery(db, delivery, target, options) {
180
+ const now = options.now ?? options.clock?.() ?? Date.now();
181
+ const timestamp = String(Math.floor(now / 1e3));
182
+ const body = JSON.stringify({
183
+ type: "questlog.events",
184
+ db: hookLabel(db),
185
+ deliveryId: delivery.id,
186
+ sentAt: new Date(now).toISOString(),
187
+ events: delivery.events
188
+ });
189
+ const signature = createHmac("sha256", target.secret).update(`${timestamp}.${body}`).digest("hex");
190
+ let status = null;
191
+ let errorClass = null;
192
+ try {
193
+ const response = await (options.fetch ?? globalThis.fetch)(target.url, {
194
+ method: "POST",
195
+ redirect: "error",
196
+ headers: {
197
+ "content-type": "application/json",
198
+ "user-agent": `questlog/${questlogVersion}`,
199
+ "x-questlog-delivery": delivery.id,
200
+ "x-questlog-timestamp": timestamp,
201
+ "x-questlog-signature": `sha256=${signature}`
202
+ },
203
+ body,
204
+ signal: AbortSignal.timeout(Math.max(1, Math.min(options.timeoutMs ?? 5e3, 5e3)))
205
+ });
206
+ status = response.status;
207
+ await response.body?.cancel();
208
+ } catch (error) {
209
+ errorClass = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name) ? error.name : "NetworkError";
210
+ }
211
+ const delivered = status != null && status >= 200 && status < 300;
212
+ const completedAt = options.now ?? options.clock?.() ?? Date.now();
213
+ withTransaction(db, () => {
214
+ if (!db.prepare("SELECT id FROM hook_targets WHERE id = ? AND claim = ?").get(target.id, target.claim))
215
+ return;
216
+ const delay = Math.min(36e5, 3e4 * 2 ** (delivery.attempts - 1));
217
+ const jitter = Number.parseInt(randomBytes(2).toString("hex"), 16) % 1e3;
561
218
  db.prepare(
562
- `UPDATE questlines
563
- SET title = ?,
564
- description = ?,
565
- starts_at = ?,
566
- due_at = ?,
567
- archived_at = ?,
568
- updated_at = ?
569
- WHERE id = ?`
219
+ "UPDATE hook_deliveries SET status = ?, http_status = ?, error_class = ?, next_attempt_at = ? WHERE id = ?"
570
220
  ).run(
571
- input.title == null ? current.title : assertNonEmpty(input.title, "Questline title"),
572
- input.description === void 0 ? current.description : input.description?.trim() || null,
573
- input.startsAt === void 0 ? current.startsAt : input.startsAt,
574
- input.dueAt === void 0 ? current.dueAt : input.dueAt,
575
- input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
576
- now,
577
- questlineId
221
+ delivered ? "delivered" : delivery.attempts >= 8 ? "failed" : "pending",
222
+ status,
223
+ errorClass,
224
+ completedAt + delay + jitter,
225
+ delivery.id
578
226
  );
579
- return getQuestlineOrThrow(db, questlineId);
227
+ db.prepare(
228
+ "UPDATE hook_targets SET claim = NULL, lease_until = NULL WHERE id = ? AND claim = ?"
229
+ ).run(target.id, target.claim);
230
+ db.prepare(
231
+ "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)"
232
+ ).run(target.id, target.id);
580
233
  });
234
+ return delivered;
581
235
  }
582
236
 
583
- // src/questlines/archive_questline.ts
584
- function archiveQuestline(db, questlineId, archivedAt) {
585
- return updateQuestline(db, questlineId, { archivedAt: resolveNow(archivedAt) });
586
- }
587
-
588
- // src/assert_active_row_exists.ts
589
- function assertActiveRowExists(db, table, id, label) {
590
- const row = db.prepare(`SELECT id FROM ${table} WHERE id = ? AND deleted_at IS NULL`).get(id);
591
- if (!row) {
592
- throw new Error(`${label} ${id} was not found.`);
593
- }
594
- }
595
-
596
- // src/questlines/create_questline.ts
597
- function createQuestline(db, input) {
598
- const now = resolveNow(input.now);
599
- const title = assertNonEmpty(input.title, "Questline title");
600
- if (input.sourceRumorId != null) {
601
- assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
602
- }
603
- const created = db.prepare(
604
- `INSERT INTO questlines (
605
- source_rumor_id,
606
- title,
607
- description,
608
- starts_at,
609
- due_at,
610
- created_at,
611
- updated_at
612
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`
613
- ).run(
614
- input.sourceRumorId ?? null,
615
- title,
616
- input.description?.trim() || null,
617
- input.startsAt ?? null,
618
- input.dueAt ?? null,
619
- now,
620
- now
621
- );
622
- return getQuestlineOrThrow(db, Number(created.lastInsertRowid));
623
- }
624
-
625
- // src/questlines/detach_quest_from_questline.ts
626
- function detachQuestFromQuestline(db, questId, now) {
627
- assertActiveRowExists(db, "quests", questId, "Quest");
628
- db.prepare(
629
- "UPDATE quests SET questline_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
630
- ).run(resolveNow(now), questId);
631
- }
632
-
633
- // src/questlines/query_questline_stats.ts
634
- function queryQuestlineStats(db, now, questlineIds) {
635
- if (questlineIds && questlineIds.length === 0) {
636
- return /* @__PURE__ */ new Map();
637
- }
638
- const params = [now, now];
639
- const questlineFilter = questlineIds && questlineIds.length > 0 ? `AND q.questline_id IN (${questlineIds.map(() => "?").join(", ")})` : "";
640
- if (questlineIds && questlineIds.length > 0) {
641
- params.push(...questlineIds);
642
- }
643
- const rows = db.prepare(
644
- `SELECT q.questline_id,
645
- COUNT(*) AS total_quests,
646
- SUM(CASE WHEN q.started_at IS NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS open_quests,
647
- SUM(CASE WHEN q.started_at IS NOT NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS in_progress_quests,
648
- SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 1 THEN 1 ELSE 0 END) AS done_quests,
649
- SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 0 THEN 1 ELSE 0 END) AS abandoned_quests,
650
- SUM(
651
- CASE
652
- WHEN q.resolved_at IS NULL
653
- AND (q.not_before_at IS NULL OR q.not_before_at <= ?)
654
- AND NOT EXISTS (
655
- SELECT 1
656
- FROM quest_unlocks qu
657
- JOIN quests blocker ON blocker.id = qu.from_quest_id
658
- WHERE qu.to_quest_id = q.id
659
- AND qu.deleted_at IS NULL
660
- AND blocker.deleted_at IS NULL
661
- AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
662
- )
663
- THEN 1
664
- ELSE 0
665
- END
666
- ) AS available_quests,
667
- SUM(
668
- CASE
669
- WHEN q.resolved_at IS NULL
670
- AND COALESCE(q.due_at, ql.due_at) IS NOT NULL
671
- AND COALESCE(q.due_at, ql.due_at) < ?
672
- THEN 1
673
- ELSE 0
674
- END
675
- ) AS overdue_quests
676
- FROM quests q
677
- LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
678
- WHERE q.deleted_at IS NULL
679
- AND q.questline_id IS NOT NULL
680
- ${questlineFilter}
681
- GROUP BY q.questline_id`
682
- ).all(...params);
683
- return new Map(
684
- rows.map((row) => [
685
- Number(row.questline_id),
237
+ // src/hooks/test_hook.ts
238
+ async function testHook(db, id, options = {}) {
239
+ const now = options.now ?? options.clock?.() ?? Date.now();
240
+ const claim = randomBytes(16).toString("hex");
241
+ const delivery = {
242
+ id: `d_${randomBytes(16).toString("hex")}`,
243
+ targetId: id,
244
+ events: [
686
245
  {
687
- totalQuests: Number(row.total_quests ?? 0),
688
- openQuests: Number(row.open_quests ?? 0),
689
- inProgressQuests: Number(row.in_progress_quests ?? 0),
690
- doneQuests: Number(row.done_quests ?? 0),
691
- abandonedQuests: Number(row.abandoned_quests ?? 0),
692
- availableQuests: Number(row.available_quests ?? 0),
693
- overdueQuests: Number(row.overdue_quests ?? 0)
246
+ id: `e_test_${randomBytes(16).toString("hex")}`,
247
+ type: "hook.test",
248
+ occurredAt: new Date(now).toISOString()
694
249
  }
695
- ])
696
- );
697
- }
698
-
699
- // src/questlines/get_questline_detail.ts
700
- function getQuestlineDetail(db, questlineId, now = Date.now()) {
701
- const questline = getQuestlineOrThrow(db, questlineId);
702
- const stats = queryQuestlineStats(db, now, [questlineId]).get(questlineId);
703
- return {
704
- ...questline,
705
- totalQuests: stats?.totalQuests ?? 0,
706
- openQuests: stats?.openQuests ?? 0,
707
- inProgressQuests: stats?.inProgressQuests ?? 0,
708
- doneQuests: stats?.doneQuests ?? 0,
709
- abandonedQuests: stats?.abandonedQuests ?? 0,
710
- availableQuests: stats?.availableQuests ?? 0,
711
- overdueQuests: stats?.overdueQuests ?? 0
250
+ ],
251
+ status: "pending",
252
+ attempts: 1,
253
+ lastAttemptAt: now,
254
+ httpStatus: null,
255
+ errorClass: null,
256
+ nextAttemptAt: now
712
257
  };
713
- }
714
-
715
- // src/questlines/list_questlines.ts
716
- function listQuestlines(db, now = Date.now()) {
717
- const questlines = db.prepare("SELECT * FROM questlines WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC").all().map(mapQuestlineRow);
718
- if (questlines.length === 0) {
719
- return [];
720
- }
721
- const statsByQuestlineId = queryQuestlineStats(
722
- db,
723
- now,
724
- questlines.map((questline) => questline.id)
725
- );
726
- return questlines.map((questline) => {
727
- const stats = statsByQuestlineId.get(questline.id);
728
- return {
729
- ...questline,
730
- totalQuests: stats?.totalQuests ?? 0,
731
- openQuests: stats?.openQuests ?? 0,
732
- inProgressQuests: stats?.inProgressQuests ?? 0,
733
- doneQuests: stats?.doneQuests ?? 0,
734
- abandonedQuests: stats?.abandonedQuests ?? 0,
735
- availableQuests: stats?.availableQuests ?? 0,
736
- overdueQuests: stats?.overdueQuests ?? 0
737
- };
258
+ const target = withTransaction(db, () => {
259
+ const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(id);
260
+ if (!row) throw new Error("Hook not found");
261
+ if (row.lease_until != null && Number(row.lease_until) > now)
262
+ throw new Error("Hook delivery is in progress");
263
+ db.prepare(
264
+ "INSERT INTO hook_deliveries(id, target_id, events, status, attempts, last_attempt_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
265
+ ).run(delivery.id, id, JSON.stringify(delivery.events), "pending", 1, now, now);
266
+ db.prepare(
267
+ "UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
268
+ ).run(claim, now + 12e4, now, id);
269
+ return { id, url: String(row.url), secret: String(row.secret), claim };
738
270
  });
739
- }
740
-
741
- // src/questlines/move_quest_to_questline.ts
742
- function moveQuestToQuestline(db, questId, questlineId, now) {
743
- assertActiveRowExists(db, "quests", questId, "Quest");
744
- assertActiveRowExists(db, "questlines", questlineId, "Questline");
745
- db.prepare(
746
- "UPDATE quests SET questline_id = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
747
- ).run(questlineId, resolveNow(now), questId);
748
- }
749
-
750
- // src/questlines/soft_delete_questline.ts
751
- function softDeleteQuestline(db, questlineId, deletedAt) {
752
- const now = resolveNow(deletedAt);
753
- assertActiveRowExists(db, "questlines", questlineId, "Questline");
754
- db.prepare(
755
- "UPDATE questlines SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
756
- ).run(now, now, questlineId);
271
+ return { id: delivery.id, delivered: await sendHookDelivery(db, delivery, target, options) };
757
272
  }
758
273
 
759
274
  // src/quests/map_quest_row.ts
@@ -796,242 +311,337 @@ function getQuestOrThrow(db, questId) {
796
311
  return mapQuestRow(row);
797
312
  }
798
313
 
799
- // src/quests/resolve_quest.ts
800
- function resolveQuest(db, questId, success, outcome, resolvedAt) {
801
- const current = getQuestOrThrow(db, questId);
802
- if (current.resolvedAt != null) {
803
- throw new Error("Resolved quests cannot be resolved again.");
804
- }
805
- const now = resolveNow(resolvedAt);
806
- db.prepare(
807
- `UPDATE quests
808
- SET outcome = ?,
809
- resolved_at = ?,
810
- success = ?,
811
- updated_at = ?
812
- WHERE id = ? AND deleted_at IS NULL`
813
- ).run(assertNonEmpty(outcome, "Quest outcome"), now, success ? 1 : 0, now, questId);
814
- return getQuestOrThrow(db, questId);
314
+ // src/resolve_now.ts
315
+ function resolveNow(now) {
316
+ return now ?? Date.now();
815
317
  }
816
318
 
817
- // src/quests/abandon_quest.ts
818
- function abandonQuest(db, questId, outcome, resolvedAt) {
819
- return resolveQuest(db, questId, false, outcome, resolvedAt);
319
+ // src/rewards/copy_repeatable_quest_rewards_to_quest.ts
320
+ function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
321
+ const effectiveNow = resolveNow(now);
322
+ const rows = db.prepare(
323
+ `SELECT kind, name, description, quantity
324
+ FROM repeatable_quest_rewards
325
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
326
+ ).all(repeatableQuestId);
327
+ for (const row of rows) {
328
+ db.prepare(
329
+ `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
330
+ VALUES (?, ?, ?, ?, ?, ?)`
331
+ ).run(
332
+ questId,
333
+ String(row.kind),
334
+ String(row.name),
335
+ row.description == null ? null : String(row.description),
336
+ row.quantity == null ? null : Number(row.quantity),
337
+ effectiveNow
338
+ );
339
+ }
820
340
  }
821
341
 
822
- // src/unlocks/would_create_unlock_cycle.ts
823
- function wouldCreateUnlockCycle(db, fromQuestId, toQuestId) {
824
- const frontier = [toQuestId];
825
- const seen = /* @__PURE__ */ new Set();
826
- while (frontier.length > 0) {
827
- const current = frontier.pop();
828
- if (current === fromQuestId) {
829
- return true;
830
- }
831
- if (seen.has(current)) {
832
- continue;
833
- }
834
- seen.add(current);
835
- const rows = db.prepare(
836
- `SELECT to_quest_id
837
- FROM quest_unlocks
838
- WHERE from_quest_id = ? AND deleted_at IS NULL`
839
- ).all(current);
840
- for (const row of rows) {
841
- frontier.push(Number(row.to_quest_id));
842
- }
342
+ // src/tags/copy_repeatable_quest_tags_to_quest.ts
343
+ function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
344
+ const effectiveNow = resolveNow(now);
345
+ const rows = db.prepare(
346
+ `SELECT tag_id
347
+ FROM repeatable_quest_tags
348
+ WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
349
+ ).all(repeatableQuestId);
350
+ for (const row of rows) {
351
+ db.prepare(
352
+ `INSERT INTO quest_tags (quest_id, tag_id, created_at)
353
+ VALUES (?, ?, ?)`
354
+ ).run(questId, Number(row.tag_id), effectiveNow);
843
355
  }
844
- return false;
845
356
  }
846
357
 
847
- // src/unlocks/add_unlock.ts
848
- function addUnlock(db, fromQuestId, toQuestId, now) {
849
- assertActiveRowExists(db, "quests", fromQuestId, "Quest");
850
- assertActiveRowExists(db, "quests", toQuestId, "Quest");
851
- if (fromQuestId === toQuestId) {
852
- throw new Error("A quest cannot unlock itself.");
853
- }
854
- if (wouldCreateUnlockCycle(db, fromQuestId, toQuestId)) {
855
- throw new Error("Unlock would create a cycle.");
856
- }
857
- db.prepare(
858
- `INSERT INTO quest_unlocks (from_quest_id, to_quest_id, created_at)
859
- VALUES (?, ?, ?)`
860
- ).run(fromQuestId, toQuestId, resolveNow(now));
358
+ // src/repeatable_quests/map_repeatable_quest_row.ts
359
+ function asBoolean2(value) {
360
+ return Number(value) === 1;
361
+ }
362
+ function mapRepeatableQuestRow(row) {
363
+ return {
364
+ id: Number(row.id),
365
+ questlineId: row.questline_id == null ? null : Number(row.questline_id),
366
+ title: String(row.title),
367
+ objective: String(row.objective),
368
+ rrule: String(row.rrule),
369
+ anchorAt: Number(row.anchor_at),
370
+ notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
371
+ dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
372
+ scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
373
+ scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
374
+ allDay: asBoolean2(row.all_day),
375
+ estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
376
+ createdAt: Number(row.created_at),
377
+ updatedAt: Number(row.updated_at),
378
+ archivedAt: row.archived_at == null ? null : Number(row.archived_at),
379
+ deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
380
+ };
861
381
  }
862
382
 
863
- // src/assert_non_negative_seconds.ts
864
- function assertNonNegativeSeconds(value, label) {
865
- if (value != null && value < 0) {
866
- throw new Error(`${label} must not be negative.`);
383
+ // src/repeatable_quests/get_repeatable_quest_or_throw.ts
384
+ function getRepeatableQuestOrThrow(db, repeatableQuestId) {
385
+ const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
386
+ if (!row) {
387
+ throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
867
388
  }
868
- return value;
389
+ return mapRepeatableQuestRow(row);
869
390
  }
870
391
 
871
- // src/assert_non_negative_number.ts
872
- function assertNonNegativeNumber(value, label) {
873
- if (value != null && value < 0) {
874
- throw new Error(`${label} must not be negative.`);
392
+ // src/repeatable_quests/advance_anchor.ts
393
+ var WEEK_MS = 7 * 864e5;
394
+ function advanceAnchor(anchorAt, freq, interval) {
395
+ const date = new Date(anchorAt);
396
+ if (freq === "MONTHLY") {
397
+ date.setUTCMonth(date.getUTCMonth() + interval);
398
+ return date.getTime();
875
399
  }
876
- return value;
400
+ if (freq === "YEARLY") {
401
+ date.setUTCFullYear(date.getUTCFullYear() + interval);
402
+ return date.getTime();
403
+ }
404
+ return anchorAt + interval * WEEK_MS;
877
405
  }
878
406
 
879
- // src/rewards/replace_active_quest_rewards.ts
880
- function replaceActiveQuestRewards(db, questId, rewards, now) {
881
- const effectiveNow = resolveNow(now);
882
- db.prepare(
883
- "UPDATE quest_rewards SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL"
884
- ).run(effectiveNow, questId);
885
- for (const reward of rewards) {
886
- db.prepare(
887
- `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
888
- VALUES (?, ?, ?, ?, ?, ?)`
889
- ).run(
890
- questId,
891
- assertNonEmpty(reward.kind, "Reward kind"),
892
- assertNonEmpty(reward.name, "Reward name"),
893
- reward.description?.trim() || null,
894
- assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
895
- effectiveNow
896
- );
407
+ // src/repeatable_quests/parse_rrule.ts
408
+ var WEEKDAY = {
409
+ SU: 0,
410
+ MO: 1,
411
+ TU: 2,
412
+ WE: 3,
413
+ TH: 4,
414
+ FR: 5,
415
+ SA: 6
416
+ };
417
+ function parseRRule(rrule) {
418
+ const parts = Object.fromEntries(
419
+ rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
420
+ const [key, value] = part.split("=");
421
+ return [key, value];
422
+ })
423
+ );
424
+ const freq = parts.FREQ;
425
+ if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
426
+ throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
427
+ }
428
+ const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
429
+ if (!Number.isFinite(interval) || interval < 1) {
430
+ throw new Error("RRULE INTERVAL must be a positive integer.");
431
+ }
432
+ const count = parts.COUNT == null ? null : Number(parts.COUNT);
433
+ if (count != null && (!Number.isFinite(count) || count < 1)) {
434
+ throw new Error("RRULE COUNT must be a positive integer.");
435
+ }
436
+ const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
437
+ if (parts.UNTIL != null && Number.isNaN(until)) {
438
+ throw new Error("RRULE UNTIL must be parseable as a date.");
897
439
  }
440
+ const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
441
+ const value = WEEKDAY[token];
442
+ if (value == null) {
443
+ throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
444
+ }
445
+ return value;
446
+ });
447
+ return { freq, interval, count, until, byDay };
898
448
  }
899
449
 
900
- // src/tags/normalize_tag_name.ts
901
- function normalizeTagName(name) {
902
- return name.trim().toLowerCase().replace(/\s+/g, " ");
450
+ // src/repeatable_quests/start_of_utc_day.ts
451
+ function startOfUtcDay(timestamp) {
452
+ const date = new Date(timestamp);
453
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
903
454
  }
904
455
 
905
- // src/tags/replace_active_quest_tags.ts
906
- function getOrCreateTagId(db, name, now) {
907
- const normalizedName = normalizeTagName(name);
908
- if (!normalizedName) {
909
- throw new Error("Tag name must not be empty.");
910
- }
911
- const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
912
- if (existing) {
913
- return Number(existing.id);
914
- }
915
- const created = db.prepare(
916
- `INSERT INTO tags (name, normalized_name, created_at)
917
- VALUES (?, ?, ?)`
918
- ).run(name.trim(), normalizedName, now);
919
- return Number(created.lastInsertRowid);
920
- }
921
- function replaceActiveQuestTags(db, questId, tagNames, now) {
922
- const effectiveNow = resolveNow(now);
923
- db.prepare("UPDATE quest_tags SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL").run(
924
- effectiveNow,
925
- questId
926
- );
927
- const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
928
- for (const name of uniqueNames) {
929
- const tagId = getOrCreateTagId(db, name, effectiveNow);
930
- db.prepare(
931
- `INSERT INTO quest_tags (quest_id, tag_id, created_at)
932
- VALUES (?, ?, ?)`
933
- ).run(questId, tagId, effectiveNow);
934
- }
456
+ // src/repeatable_quests/start_of_utc_week.ts
457
+ var DAY_MS = 864e5;
458
+ function startOfUtcWeek(dayStart) {
459
+ const date = new Date(dayStart);
460
+ return dayStart - date.getUTCDay() * DAY_MS;
935
461
  }
936
462
 
937
- // src/quests/create_quest.ts
938
- function createQuest(db, input) {
939
- return withTransaction(db, () => {
940
- const now = resolveNow(input.now);
941
- const title = assertNonEmpty(input.title, "Quest title");
942
- const objective = assertNonEmpty(input.objective, "Quest objective");
943
- if (input.questlineId != null) {
944
- assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
463
+ // src/repeatable_quests/generate_anchors_until.ts
464
+ var DAY_MS2 = 864e5;
465
+ var WEEK_MS2 = 7 * DAY_MS2;
466
+ function generateAnchorsUntil(repeatable, now) {
467
+ const rule = parseRRule(repeatable.rrule);
468
+ const anchors = [];
469
+ const until = rule.until == null ? now : Math.min(now, rule.until);
470
+ if (repeatable.anchorAt > until) {
471
+ return anchors;
472
+ }
473
+ if (rule.freq === "DAILY") {
474
+ let occurrence2 = 0;
475
+ for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
476
+ occurrence2 += 1;
477
+ if (rule.count != null && occurrence2 > rule.count) {
478
+ break;
479
+ }
480
+ anchors.push(current2);
945
481
  }
946
- if (input.sourceRumorId != null) {
947
- assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
482
+ return anchors;
483
+ }
484
+ if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
485
+ const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
486
+ const anchorWeekStart = startOfUtcWeek(anchorDayStart);
487
+ const timeOffset = repeatable.anchorAt - anchorDayStart;
488
+ let occurrence2 = 0;
489
+ for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
490
+ const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
491
+ if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
492
+ continue;
493
+ }
494
+ if (!rule.byDay.includes(new Date(day).getUTCDay())) {
495
+ continue;
496
+ }
497
+ const anchor = day + timeOffset;
498
+ if (anchor < repeatable.anchorAt || anchor > until) {
499
+ continue;
500
+ }
501
+ occurrence2 += 1;
502
+ if (rule.count != null && occurrence2 > rule.count) {
503
+ break;
504
+ }
505
+ anchors.push(anchor);
948
506
  }
949
- if (input.spawnedByQuestId != null) {
950
- assertActiveRowExists(db, "quests", input.spawnedByQuestId, "Quest");
507
+ return anchors;
508
+ }
509
+ let occurrence = 0;
510
+ let current = repeatable.anchorAt;
511
+ while (current <= until) {
512
+ occurrence += 1;
513
+ if (rule.count != null && occurrence > rule.count) {
514
+ break;
951
515
  }
952
- if (input.spawnedFromRepeatableId != null) {
953
- assertActiveRowExists(
954
- db,
955
- "repeatable_quests",
956
- input.spawnedFromRepeatableId,
957
- "Repeatable quest"
958
- );
959
- }
960
- assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate");
961
- const created = db.prepare(
962
- `INSERT INTO quests (
963
- questline_id,
964
- source_rumor_id,
965
- spawned_by_quest_id,
966
- spawned_from_repeatable_id,
967
- spawned_for_at,
968
- title,
969
- objective,
970
- created_at,
971
- updated_at,
972
- effort_seconds,
973
- estimate_seconds,
974
- not_before_at,
975
- due_at,
976
- scheduled_start_at,
977
- scheduled_end_at,
978
- all_day
979
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
980
- ).run(
981
- input.questlineId ?? null,
982
- input.sourceRumorId ?? null,
983
- input.spawnedByQuestId ?? null,
984
- input.spawnedFromRepeatableId ?? null,
985
- input.spawnedForAt ?? null,
986
- title,
987
- objective,
988
- now,
989
- now,
990
- input.estimateSeconds ?? null,
991
- input.notBeforeAt ?? null,
992
- input.dueAt ?? null,
993
- input.scheduledStartAt ?? null,
994
- input.scheduledEndAt ?? null,
995
- input.allDay ? 1 : 0
996
- );
997
- const questId = Number(created.lastInsertRowid);
998
- if (input.tags && input.tags.length > 0) {
999
- replaceActiveQuestTags(db, questId, input.tags, now);
1000
- }
1001
- if (input.rewards && input.rewards.length > 0) {
1002
- replaceActiveQuestRewards(db, questId, input.rewards, now);
516
+ anchors.push(current);
517
+ current = advanceAnchor(current, rule.freq, rule.interval);
518
+ }
519
+ return anchors;
520
+ }
521
+
522
+ // src/repeatable_quests/list_due_repeatable_quest_anchors.ts
523
+ function listDueRepeatableQuestAnchors(db, now) {
524
+ const repeatables = db.prepare(
525
+ `SELECT *
526
+ FROM repeatable_quests
527
+ WHERE deleted_at IS NULL
528
+ AND archived_at IS NULL`
529
+ ).all().map(mapRepeatableQuestRow);
530
+ const anchors = [];
531
+ if (repeatables.length === 0) {
532
+ return anchors;
533
+ }
534
+ const repeatableIds = repeatables.map((repeatable) => repeatable.id);
535
+ const maxAnchorAt = Math.max(
536
+ ...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
537
+ );
538
+ const existingRows = db.prepare(
539
+ `SELECT spawned_from_repeatable_id, spawned_for_at
540
+ FROM quests
541
+ WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
542
+ AND spawned_for_at IS NOT NULL
543
+ AND spawned_for_at <= ?`
544
+ ).all(...repeatableIds, maxAnchorAt);
545
+ const existingAnchors = new Set(
546
+ existingRows.map(
547
+ (row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
548
+ )
549
+ );
550
+ for (const repeatable of repeatables) {
551
+ const dueAnchors = generateAnchorsUntil(repeatable, now);
552
+ for (const anchorAt of dueAnchors) {
553
+ if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
554
+ anchors.push({
555
+ repeatableQuestId: repeatable.id,
556
+ anchorAt,
557
+ markerId: "attention.available.repeatable"
558
+ });
559
+ }
1003
560
  }
1004
- return getQuestOrThrow(db, questId);
1005
- });
561
+ }
562
+ return anchors.sort(
563
+ (a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
564
+ );
1006
565
  }
1007
566
 
1008
- // src/quests/abandon_quest_and_spawn_followups.ts
1009
- function abandonQuestAndSpawnFollowups(db, questId, outcome, followups, resolvedAt) {
567
+ // src/repeatable_quests/spawn_due_repeatable_quests.ts
568
+ function spawnDueRepeatableQuests(db, now) {
1010
569
  return withTransaction(db, () => {
1011
- const abandonedQuest = abandonQuest(db, questId, outcome, resolvedAt);
1012
- const now = resolveNow(resolvedAt);
1013
- const followupQuests = followups.map(
1014
- (followup) => createQuest(db, {
1015
- ...followup,
1016
- questlineId: followup.questlineId ?? abandonedQuest.questlineId,
1017
- spawnedByQuestId: questId,
1018
- now
1019
- })
1020
- );
1021
- for (let i = 0; i < followups.length; i += 1) {
1022
- const followup = followups[i];
1023
- const created = followupQuests[i];
1024
- if (followup?.unlocksParent && created) {
1025
- addUnlock(db, created.id, questId, resolvedAt);
1026
- }
570
+ const anchors = listDueRepeatableQuestAnchors(db, now);
571
+ const quests = [];
572
+ for (const anchor of anchors) {
573
+ const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
574
+ const created = db.prepare(
575
+ `INSERT INTO quests (
576
+ questline_id,
577
+ source_rumor_id,
578
+ spawned_by_quest_id,
579
+ spawned_from_repeatable_id,
580
+ spawned_for_at,
581
+ title,
582
+ objective,
583
+ created_at,
584
+ updated_at,
585
+ effort_seconds,
586
+ estimate_seconds,
587
+ not_before_at,
588
+ due_at,
589
+ scheduled_start_at,
590
+ scheduled_end_at,
591
+ all_day
592
+ ) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
593
+ ).run(
594
+ repeatable.questlineId ?? null,
595
+ repeatable.id,
596
+ anchor.anchorAt,
597
+ repeatable.title,
598
+ repeatable.objective,
599
+ now,
600
+ now,
601
+ repeatable.estimateSeconds ?? null,
602
+ repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
603
+ repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
604
+ repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
605
+ repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
606
+ repeatable.allDay ? 1 : 0
607
+ );
608
+ const questId = Number(created.lastInsertRowid);
609
+ copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
610
+ copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
611
+ quests.push(getQuestOrThrow(db, questId));
1027
612
  }
1028
- return { abandonedQuest, followupQuests };
613
+ return quests;
1029
614
  });
1030
615
  }
1031
616
 
1032
- // src/quests/finish_quest.ts
1033
- function finishQuest(db, questId, outcome, resolvedAt) {
1034
- return resolveQuest(db, questId, true, outcome, resolvedAt);
617
+ // src/markers/derive_quest_marker_id.ts
618
+ function deriveQuestMarkerId({
619
+ state,
620
+ available,
621
+ hasUnclaimedReward,
622
+ notBeforeAt,
623
+ blockerCount,
624
+ now
625
+ }) {
626
+ if (state === "done") {
627
+ return hasUnclaimedReward ? "progress.complete" : null;
628
+ }
629
+ if (state === "abandoned") {
630
+ return null;
631
+ }
632
+ if (state === "in_progress") {
633
+ return "progress.incomplete";
634
+ }
635
+ if (available) {
636
+ return "attention.available";
637
+ }
638
+ if (notBeforeAt != null && notBeforeAt > now) {
639
+ return "attention.available.future";
640
+ }
641
+ if (blockerCount > 0) {
642
+ return "progress.incomplete";
643
+ }
644
+ return null;
1035
645
  }
1036
646
 
1037
647
  // src/rewards/map_quest_reward_row.ts
@@ -1207,332 +817,985 @@ function queryQuestDetailRecords(db, filters = {}, now = Date.now(), questIds) {
1207
817
  });
1208
818
  }
1209
819
 
1210
- // src/quests/get_quest_detail.ts
1211
- function getQuestDetail(db, questId, now = Date.now()) {
1212
- const [record] = queryQuestDetailRecords(db, {}, now, [questId]);
1213
- if (!record) {
1214
- throw new Error(`Quest ${questId} was not found.`);
820
+ // src/hooks/detect_hook_events.ts
821
+ function detectHookEvents(db, now) {
822
+ const events = [];
823
+ const add = (type, at, ids) => {
824
+ events.push({
825
+ id: `e_${type}_${ids.questId ?? ids.rumorId}_${ids.repeatableId ?? ""}_${at}`,
826
+ type,
827
+ ...ids,
828
+ occurredAt: new Date(at).toISOString()
829
+ });
830
+ };
831
+ for (const { detail: q } of queryQuestDetailRecords(db, {}, now)) {
832
+ if (q.resolvedAt == null && q.effectiveDueAt != null && q.effectiveDueAt <= now) {
833
+ add("quest.due", q.effectiveDueAt, { questId: q.id });
834
+ if (q.effectiveDueAt < now) add("quest.overdue", q.effectiveDueAt, { questId: q.id });
835
+ }
836
+ if (q.available) {
837
+ const prerequisites = db.prepare(
838
+ `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`
839
+ ).all(q.id);
840
+ if (prerequisites.some((p) => Number(p.resolved_at) >= Number(p.created_at))) {
841
+ const at = Math.max(q.notBeforeAt ?? 0, ...prerequisites.map((p) => Number(p.resolved_at)));
842
+ if (at <= now) add("quest.unblocked", at, { questId: q.id });
843
+ }
844
+ }
845
+ if (q.spawnedFromRepeatableId != null && q.createdAt <= now)
846
+ add("repeatable.spawned", q.createdAt, {
847
+ questId: q.id,
848
+ repeatableId: q.spawnedFromRepeatableId
849
+ });
1215
850
  }
1216
- return record.detail;
1217
- }
1218
-
1219
- // src/quests/list_quest_details.ts
1220
- function listQuestDetails(db, filters = {}, now = Date.now()) {
1221
- return queryQuestDetailRecords(db, filters, now).map((record) => record.detail);
851
+ for (const row of db.prepare("SELECT id, created_at FROM rumors WHERE deleted_at IS NULL AND created_at <= ?").all(now))
852
+ add("rumor.captured", Number(row.created_at), { rumorId: Number(row.id) });
853
+ return events.sort(
854
+ (a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id)
855
+ );
1222
856
  }
1223
857
 
1224
- // src/quests/list_active_quests.ts
1225
- function listActiveQuests(db, filters = {}, now = Date.now()) {
1226
- return listQuestDetails(db, filters, now).filter(
1227
- (quest) => quest.state === "open" || quest.state === "in_progress"
858
+ // src/hooks/tick_questlog.ts
859
+ async function tickQuestlog(db, options = {}) {
860
+ const now = options.now ?? options.clock?.() ?? Date.now();
861
+ const result = {
862
+ events: [],
863
+ batches: [],
864
+ sent: 0,
865
+ queued: 0,
866
+ retrying: 0,
867
+ failed: 0
868
+ };
869
+ const claims = [];
870
+ const collect = () => {
871
+ spawnDueRepeatableQuests(db, now);
872
+ const detected = detectHookEvents(db, now);
873
+ result.events = detected.filter(
874
+ (e) => !db.prepare("SELECT id FROM hook_events WHERE id = ?").get(e.id)
875
+ );
876
+ for (const event of result.events)
877
+ db.prepare("INSERT INTO hook_events(id, occurred_at, event) VALUES (?, ?, ?)").run(
878
+ event.id,
879
+ Date.parse(event.occurredAt),
880
+ JSON.stringify(event)
881
+ );
882
+ for (const target of listHooks(db)) {
883
+ const events = db.prepare(
884
+ "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"
885
+ ).all(target.createdAt, target.id).map((row2) => JSON.parse(String(row2.event))).filter((event) => {
886
+ if (!target.events.includes(event.type)) return false;
887
+ if (target.tag == null) return true;
888
+ return event.questId !== void 0 && db.prepare(
889
+ "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 = ?"
890
+ ).get(event.questId, normalizeTagName(target.tag)) !== void 0;
891
+ });
892
+ for (const event of events)
893
+ db.prepare("INSERT INTO hook_assignments(target_id, event_id) VALUES (?, ?)").run(
894
+ target.id,
895
+ event.id
896
+ );
897
+ const row = db.prepare("SELECT url, secret, lease_until FROM hook_targets WHERE id = ?").get(target.id);
898
+ if (!row || row.lease_until != null && Number(row.lease_until) > now) continue;
899
+ let pending = listDeliveries(db).find(
900
+ (d) => d.targetId === target.id && d.status === "pending"
901
+ );
902
+ const queued = db.prepare(
903
+ "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"
904
+ ).all(target.id).map((r) => JSON.parse(String(r.event)));
905
+ if (!pending && queued.length) {
906
+ const id = `d_${randomBytes(16).toString("hex")}`;
907
+ db.prepare(
908
+ "INSERT INTO hook_deliveries(id, target_id, events, status, next_attempt_at) VALUES (?, ?, ?, 'pending', ?)"
909
+ ).run(
910
+ id,
911
+ target.id,
912
+ JSON.stringify(queued),
913
+ Math.max(now, (target.lastWake ?? -Infinity) + target.minGap * 1e3)
914
+ );
915
+ db.prepare(
916
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
917
+ ).run(id, target.id);
918
+ pending = listDeliveries(db).find((d) => d.id === id);
919
+ }
920
+ if (!pending) continue;
921
+ if (pending.attempts === 0 && queued.length && !queued.every((e) => pending?.events.some((p) => p.id === e.id))) {
922
+ pending.events.push(...queued);
923
+ db.prepare("UPDATE hook_deliveries SET events = ? WHERE id = ?").run(
924
+ JSON.stringify(pending.events),
925
+ pending.id
926
+ );
927
+ db.prepare(
928
+ "UPDATE hook_assignments SET delivery_id = ? WHERE target_id = ? AND delivery_id IS NULL"
929
+ ).run(pending.id, target.id);
930
+ }
931
+ if (pending.nextAttemptAt > now || target.lastWake != null && target.lastWake + target.minGap * 1e3 > now)
932
+ continue;
933
+ if (pending.attempts >= 8) {
934
+ db.prepare("UPDATE hook_deliveries SET status = 'failed' WHERE id = ?").run(pending.id);
935
+ continue;
936
+ }
937
+ const claim = randomBytes(16).toString("hex");
938
+ db.prepare(
939
+ "UPDATE hook_targets SET claim = ?, lease_until = ?, last_wake = ? WHERE id = ?"
940
+ ).run(claim, now + 12e4, now, target.id);
941
+ db.prepare(
942
+ "UPDATE hook_deliveries SET attempts = attempts + 1, last_attempt_at = ? WHERE id = ?"
943
+ ).run(now, pending.id);
944
+ pending.attempts++;
945
+ pending.lastAttemptAt = now;
946
+ result.batches.push({ targetId: target.id, events: pending.events });
947
+ claims.push({
948
+ delivery: pending,
949
+ target: { id: target.id, url: String(row.url), secret: String(row.secret), claim }
950
+ });
951
+ }
952
+ };
953
+ if (options.dryRun) {
954
+ const rollback = new Error("preview rollback");
955
+ try {
956
+ withTransaction(db, () => {
957
+ collect();
958
+ throw rollback;
959
+ });
960
+ } catch (error) {
961
+ if (error !== rollback) throw error;
962
+ }
963
+ } else withTransaction(db, collect);
964
+ if (!options.dryRun) {
965
+ const sent = await Promise.all(
966
+ claims.map((claim) => sendHookDelivery(db, claim.delivery, claim.target, options))
967
+ );
968
+ result.sent = sent.filter(Boolean).length;
969
+ }
970
+ const deliveries = listDeliveries(db);
971
+ result.queued = deliveries.filter((d) => d.status === "pending" && d.attempts === 0).length + Number(
972
+ db.prepare(
973
+ "SELECT COUNT(DISTINCT target_id) AS n FROM hook_assignments WHERE delivery_id IS NULL"
974
+ ).get()?.n ?? 0
1228
975
  );
976
+ result.retrying = deliveries.filter((d) => d.status === "pending" && d.attempts > 0).length;
977
+ result.failed = deliveries.filter((d) => d.status === "failed").length;
978
+ return result;
1229
979
  }
1230
980
 
1231
- // src/quests/list_available_quests.ts
1232
- function listAvailableQuests(db, filters = {}, now = Date.now()) {
1233
- return listQuestDetails(db, filters, now).filter((quest) => quest.available);
981
+ // src/hooks/watch_questlog.ts
982
+ async function watchQuestlog(db, options) {
983
+ const interval = options.interval ?? 60;
984
+ if (!Number.isFinite(interval) || interval < 5)
985
+ throw new Error("Interval must be at least 5 seconds");
986
+ while (!options.signal.aborted) {
987
+ options.onTick?.(await tickQuestlog(db, options));
988
+ if (options.signal.aborted) break;
989
+ await new Promise((resolve) => {
990
+ const finish = () => {
991
+ clearTimeout(timer);
992
+ options.signal.removeEventListener("abort", finish);
993
+ resolve();
994
+ };
995
+ const timer = setTimeout(finish, interval * 1e3);
996
+ options.signal.addEventListener("abort", finish, { once: true });
997
+ });
998
+ }
1234
999
  }
1235
1000
 
1236
- // src/quests/list_blocked_quests.ts
1237
- function listBlockedQuests(db, filters = {}, now = Date.now()) {
1238
- return queryQuestDetailRecords(db, filters, now).filter(
1239
- (record) => (record.detail.state === "open" || record.detail.state === "in_progress") && record.blockerCount > 0
1240
- ).map((record) => record.detail);
1001
+ // src/hooks/init_hook_tables.ts
1002
+ function initHookTables(db) {
1003
+ db.exec(`
1004
+ CREATE TABLE IF NOT EXISTS hook_targets (
1005
+ id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, secret TEXT NOT NULL,
1006
+ tag TEXT, events TEXT NOT NULL, min_gap INTEGER NOT NULL, created_at INTEGER NOT NULL,
1007
+ last_wake INTEGER, lease_until INTEGER, claim TEXT);
1008
+ CREATE TABLE IF NOT EXISTS hook_events (
1009
+ id TEXT PRIMARY KEY, occurred_at INTEGER NOT NULL, event TEXT NOT NULL);
1010
+ CREATE TABLE IF NOT EXISTS hook_assignments (
1011
+ target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
1012
+ event_id TEXT NOT NULL REFERENCES hook_events(id), delivery_id TEXT,
1013
+ PRIMARY KEY(target_id, event_id));
1014
+ CREATE TABLE IF NOT EXISTS hook_deliveries (
1015
+ id TEXT PRIMARY KEY, target_id INTEGER NOT NULL REFERENCES hook_targets(id) ON DELETE CASCADE,
1016
+ events TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
1017
+ last_attempt_at INTEGER, http_status INTEGER, error_class TEXT,
1018
+ next_attempt_at INTEGER NOT NULL);
1019
+ CREATE INDEX IF NOT EXISTS idx_hook_deliveries_target ON hook_deliveries(target_id, status, next_attempt_at);
1020
+ CREATE TABLE IF NOT EXISTS hook_settings (id INTEGER PRIMARY KEY CHECK(id = 1), label TEXT NOT NULL);
1021
+ `);
1241
1022
  }
1242
1023
 
1243
- // src/quests/list_deferred_quests.ts
1244
- function listDeferredQuests(db, filters = {}, now = Date.now()) {
1245
- return listQuestDetails(db, filters, now).filter(
1246
- (quest) => quest.resolvedAt == null && quest.notBeforeAt != null && quest.notBeforeAt > now
1024
+ // src/questlines/init_questline_tables.ts
1025
+ function initQuestlineTables(db) {
1026
+ db.exec(`
1027
+ CREATE TABLE IF NOT EXISTS questlines (
1028
+ id INTEGER PRIMARY KEY,
1029
+ source_rumor_id INTEGER REFERENCES rumors(id),
1030
+ title TEXT NOT NULL,
1031
+ description TEXT,
1032
+ starts_at INTEGER,
1033
+ due_at INTEGER,
1034
+ created_at INTEGER NOT NULL,
1035
+ updated_at INTEGER NOT NULL,
1036
+ archived_at INTEGER,
1037
+ deleted_at INTEGER,
1038
+ CHECK(length(trim(title)) > 0),
1039
+ CHECK(due_at IS NULL OR starts_at IS NULL OR due_at >= starts_at)
1040
+ )
1041
+ `);
1042
+ db.exec("CREATE INDEX IF NOT EXISTS idx_questlines_due_at ON questlines(due_at)");
1043
+ db.exec("CREATE INDEX IF NOT EXISTS idx_questlines_deleted_at ON questlines(deleted_at)");
1044
+ db.exec(
1045
+ "CREATE INDEX IF NOT EXISTS idx_questlines_source_rumor_id ON questlines(source_rumor_id) WHERE deleted_at IS NULL"
1247
1046
  );
1248
- }
1249
-
1250
- // src/quests/list_due_soon_quests.ts
1251
- function listDueSoonQuests(db, horizonMs, filters = {}, now = Date.now()) {
1252
- return listQuestDetails(db, filters, now).filter(
1253
- (quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt >= now && quest.effectiveDueAt <= now + horizonMs
1047
+ db.exec(
1048
+ "CREATE INDEX IF NOT EXISTS idx_questlines_created_at_active ON questlines(created_at DESC, id DESC) WHERE deleted_at IS NULL"
1254
1049
  );
1255
1050
  }
1256
1051
 
1257
- // src/quests/list_in_progress_quests.ts
1258
- function listInProgressQuests(db, filters = {}, now = Date.now()) {
1259
- return listQuestDetails(db, filters, now).filter((quest) => quest.state === "in_progress");
1052
+ // src/quests/init_quest_search.ts
1053
+ function initQuestSearch(db) {
1054
+ db.exec(`
1055
+ CREATE VIRTUAL TABLE IF NOT EXISTS quests_fts USING fts5(
1056
+ title,
1057
+ objective,
1058
+ outcome,
1059
+ content=quests,
1060
+ content_rowid=id
1061
+ )
1062
+ `);
1063
+ db.exec(`
1064
+ CREATE TRIGGER IF NOT EXISTS quests_fts_insert
1065
+ AFTER INSERT ON quests
1066
+ WHEN new.deleted_at IS NULL
1067
+ BEGIN
1068
+ INSERT INTO quests_fts(rowid, title, objective, outcome)
1069
+ VALUES (new.id, new.title, new.objective, COALESCE(new.outcome, ''));
1070
+ END
1071
+ `);
1072
+ db.exec(`
1073
+ CREATE TRIGGER IF NOT EXISTS quests_fts_update
1074
+ AFTER UPDATE OF title, objective, outcome, deleted_at ON quests
1075
+ BEGIN
1076
+ INSERT INTO quests_fts(quests_fts, rowid, title, objective, outcome)
1077
+ VALUES ('delete', old.id, old.title, old.objective, COALESCE(old.outcome, ''));
1078
+ INSERT INTO quests_fts(rowid, title, objective, outcome)
1079
+ SELECT new.id, new.title, new.objective, COALESCE(new.outcome, '')
1080
+ WHERE new.deleted_at IS NULL;
1081
+ END
1082
+ `);
1260
1083
  }
1261
1084
 
1262
- // src/quests/list_missed_scheduled_quests.ts
1263
- function listMissedScheduledQuests(db, filters = {}, now = Date.now()) {
1264
- return listQuestDetails(db, filters, now).filter(
1265
- (quest) => quest.resolvedAt == null && quest.scheduledEndAt != null && quest.scheduledEndAt < now
1085
+ // src/quests/init_quest_tables.ts
1086
+ function initQuestTables(db) {
1087
+ db.exec(`
1088
+ CREATE TABLE IF NOT EXISTS quests (
1089
+ id INTEGER PRIMARY KEY,
1090
+ questline_id INTEGER REFERENCES questlines(id),
1091
+ source_rumor_id INTEGER REFERENCES rumors(id),
1092
+ spawned_by_quest_id INTEGER REFERENCES quests(id),
1093
+ spawned_from_repeatable_id INTEGER REFERENCES repeatable_quests(id),
1094
+ spawned_for_at INTEGER,
1095
+ title TEXT NOT NULL,
1096
+ objective TEXT NOT NULL,
1097
+ outcome TEXT,
1098
+ created_at INTEGER NOT NULL,
1099
+ updated_at INTEGER NOT NULL,
1100
+ started_at INTEGER,
1101
+ resolved_at INTEGER,
1102
+ success INTEGER CHECK(success IN (0, 1) OR success IS NULL),
1103
+ effort_seconds INTEGER NOT NULL DEFAULT 0 CHECK(effort_seconds >= 0),
1104
+ estimate_seconds INTEGER CHECK(estimate_seconds IS NULL OR estimate_seconds >= 0),
1105
+ not_before_at INTEGER,
1106
+ due_at INTEGER,
1107
+ scheduled_start_at INTEGER,
1108
+ scheduled_end_at INTEGER,
1109
+ all_day INTEGER NOT NULL DEFAULT 0 CHECK(all_day IN (0, 1)),
1110
+ deleted_at INTEGER,
1111
+ CHECK(length(trim(title)) > 0),
1112
+ CHECK(length(trim(objective)) > 0),
1113
+ CHECK(
1114
+ (resolved_at IS NULL AND success IS NULL AND outcome IS NULL)
1115
+ OR (resolved_at IS NOT NULL AND success IS NOT NULL AND outcome IS NOT NULL)
1116
+ ),
1117
+ CHECK(started_at IS NULL OR started_at >= created_at),
1118
+ CHECK(resolved_at IS NULL OR resolved_at >= created_at),
1119
+ CHECK(resolved_at IS NULL OR started_at IS NULL OR resolved_at >= started_at),
1120
+ CHECK(scheduled_end_at IS NULL OR scheduled_start_at IS NOT NULL),
1121
+ CHECK(scheduled_end_at IS NULL OR scheduled_end_at >= scheduled_start_at),
1122
+ CHECK(not_before_at IS NULL OR due_at IS NULL OR not_before_at <= due_at),
1123
+ CHECK(
1124
+ (spawned_from_repeatable_id IS NULL AND spawned_for_at IS NULL)
1125
+ OR (spawned_from_repeatable_id IS NOT NULL AND spawned_for_at IS NOT NULL)
1126
+ )
1127
+ )
1128
+ `);
1129
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quests_deleted_at ON quests(deleted_at)");
1130
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quests_questline_id ON quests(questline_id)");
1131
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quests_source_rumor_id ON quests(source_rumor_id)");
1132
+ db.exec(
1133
+ "CREATE INDEX IF NOT EXISTS idx_quests_spawned_by_quest_id ON quests(spawned_by_quest_id)"
1266
1134
  );
1267
- }
1268
-
1269
- // src/quests/list_open_quests.ts
1270
- function listOpenQuests(db, filters = {}, now = Date.now()) {
1271
- return listQuestDetails(db, filters, now).filter((quest) => quest.state === "open");
1272
- }
1273
-
1274
- // src/quests/list_overdue_quests.ts
1275
- function listOverdueQuests(db, filters = {}, now = Date.now()) {
1276
- return listQuestDetails(db, filters, now).filter(
1277
- (quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt < now
1135
+ db.exec(
1136
+ "CREATE INDEX IF NOT EXISTS idx_quests_repeatable_anchor ON quests(spawned_from_repeatable_id, spawned_for_at)"
1137
+ );
1138
+ db.exec(
1139
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_quests_repeatable_anchor_unique ON quests(spawned_from_repeatable_id, spawned_for_at) WHERE spawned_from_repeatable_id IS NOT NULL AND spawned_for_at IS NOT NULL AND deleted_at IS NULL"
1140
+ );
1141
+ db.exec(
1142
+ "CREATE INDEX IF NOT EXISTS idx_quests_due_at ON quests(due_at) WHERE deleted_at IS NULL"
1143
+ );
1144
+ db.exec(
1145
+ "CREATE INDEX IF NOT EXISTS idx_quests_not_before_at ON quests(not_before_at) WHERE deleted_at IS NULL"
1146
+ );
1147
+ db.exec(
1148
+ "CREATE INDEX IF NOT EXISTS idx_quests_scheduled_window ON quests(scheduled_start_at, scheduled_end_at) WHERE deleted_at IS NULL"
1149
+ );
1150
+ db.exec(
1151
+ "CREATE INDEX IF NOT EXISTS idx_quests_lifecycle ON quests(started_at, resolved_at, success) WHERE deleted_at IS NULL"
1152
+ );
1153
+ db.exec(
1154
+ "CREATE INDEX IF NOT EXISTS idx_quests_created_at_active ON quests(created_at DESC, id DESC) WHERE deleted_at IS NULL"
1278
1155
  );
1279
1156
  }
1280
1157
 
1281
- // src/quests/list_resolved_quests.ts
1282
- function listResolvedQuests(db, filters = {}, now = Date.now()) {
1283
- return listQuestDetails(db, filters, now).filter((quest) => quest.resolvedAt != null);
1284
- }
1285
-
1286
- // src/quests/overlaps_schedule_window.ts
1287
- function overlapsScheduleWindow(start, end, windowStart, windowEnd) {
1288
- if (start == null) {
1289
- return false;
1290
- }
1291
- const effectiveEnd = end ?? start;
1292
- return start < windowEnd && effectiveEnd >= windowStart;
1293
- }
1294
-
1295
- // src/quests/list_scheduled_for_day.ts
1296
- function listScheduledForDay(db, dayStart, dayEnd, filters = {}, now = Date.now()) {
1297
- return listQuestDetails(db, filters, now).filter(
1298
- (quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, dayStart, dayEnd)
1158
+ // src/repeatable_quests/init_repeatable_quest_tables.ts
1159
+ function initRepeatableQuestTables(db) {
1160
+ db.exec(`
1161
+ CREATE TABLE IF NOT EXISTS repeatable_quests (
1162
+ id INTEGER PRIMARY KEY,
1163
+ questline_id INTEGER REFERENCES questlines(id),
1164
+ title TEXT NOT NULL,
1165
+ objective TEXT NOT NULL,
1166
+ rrule TEXT NOT NULL,
1167
+ anchor_at INTEGER NOT NULL,
1168
+ not_before_offset_seconds INTEGER,
1169
+ due_offset_seconds INTEGER,
1170
+ scheduled_start_offset_seconds INTEGER,
1171
+ scheduled_end_offset_seconds INTEGER,
1172
+ all_day INTEGER NOT NULL DEFAULT 0 CHECK(all_day IN (0, 1)),
1173
+ estimate_seconds INTEGER CHECK(estimate_seconds IS NULL OR estimate_seconds >= 0),
1174
+ created_at INTEGER NOT NULL,
1175
+ updated_at INTEGER NOT NULL,
1176
+ archived_at INTEGER,
1177
+ deleted_at INTEGER,
1178
+ CHECK(length(trim(title)) > 0),
1179
+ CHECK(length(trim(objective)) > 0),
1180
+ CHECK(scheduled_end_offset_seconds IS NULL OR scheduled_start_offset_seconds IS NOT NULL),
1181
+ CHECK(
1182
+ scheduled_end_offset_seconds IS NULL
1183
+ OR scheduled_end_offset_seconds >= scheduled_start_offset_seconds
1184
+ )
1185
+ )
1186
+ `);
1187
+ db.exec(
1188
+ "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_deleted_at ON repeatable_quests(deleted_at)"
1189
+ );
1190
+ db.exec(
1191
+ "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_archived_at ON repeatable_quests(archived_at)"
1192
+ );
1193
+ db.exec(
1194
+ "CREATE INDEX IF NOT EXISTS idx_repeatable_quests_active_anchor ON repeatable_quests(anchor_at) WHERE deleted_at IS NULL AND archived_at IS NULL"
1299
1195
  );
1300
1196
  }
1301
1197
 
1302
- // src/quests/list_scheduled_now.ts
1303
- function listScheduledNow(db, filters = {}, now = Date.now()) {
1304
- return listQuestDetails(db, filters, now).filter(
1305
- (quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, now, now + 1)
1198
+ // src/rewards/init_reward_tables.ts
1199
+ function initRewardTables(db) {
1200
+ db.exec(`
1201
+ CREATE TABLE IF NOT EXISTS quest_rewards (
1202
+ id INTEGER PRIMARY KEY,
1203
+ quest_id INTEGER NOT NULL REFERENCES quests(id),
1204
+ kind TEXT NOT NULL,
1205
+ name TEXT NOT NULL,
1206
+ description TEXT,
1207
+ quantity REAL CHECK(quantity IS NULL OR quantity >= 0),
1208
+ created_at INTEGER NOT NULL,
1209
+ claimed_at INTEGER,
1210
+ deleted_at INTEGER,
1211
+ CHECK(length(trim(kind)) > 0),
1212
+ CHECK(length(trim(name)) > 0)
1213
+ )
1214
+ `);
1215
+ db.exec(
1216
+ "CREATE INDEX IF NOT EXISTS idx_quest_rewards_quest_id ON quest_rewards(quest_id) WHERE deleted_at IS NULL"
1217
+ );
1218
+ db.exec(
1219
+ "CREATE INDEX IF NOT EXISTS idx_quest_rewards_claimed_at ON quest_rewards(claimed_at) WHERE deleted_at IS NULL"
1220
+ );
1221
+ db.exec(`
1222
+ CREATE TABLE IF NOT EXISTS repeatable_quest_rewards (
1223
+ id INTEGER PRIMARY KEY,
1224
+ repeatable_quest_id INTEGER NOT NULL REFERENCES repeatable_quests(id),
1225
+ kind TEXT NOT NULL,
1226
+ name TEXT NOT NULL,
1227
+ description TEXT,
1228
+ quantity REAL CHECK(quantity IS NULL OR quantity >= 0),
1229
+ created_at INTEGER NOT NULL,
1230
+ deleted_at INTEGER,
1231
+ CHECK(length(trim(kind)) > 0),
1232
+ CHECK(length(trim(name)) > 0)
1233
+ )
1234
+ `);
1235
+ db.exec(
1236
+ "CREATE INDEX IF NOT EXISTS idx_repeatable_quest_rewards_repeatable_quest_id ON repeatable_quest_rewards(repeatable_quest_id) WHERE deleted_at IS NULL"
1306
1237
  );
1307
1238
  }
1308
1239
 
1309
- // src/quests/log_quest_effort.ts
1310
- function logQuestEffort(db, questId, effortSeconds, now) {
1311
- if (effortSeconds <= 0) {
1312
- throw new Error("Quest effort increment must be greater than zero.");
1313
- }
1314
- getQuestOrThrow(db, questId);
1315
- db.prepare(
1316
- `UPDATE quests
1317
- SET effort_seconds = effort_seconds + ?, updated_at = ?
1318
- WHERE id = ? AND deleted_at IS NULL`
1319
- ).run(effortSeconds, resolveNow(now), questId);
1320
- return getQuestOrThrow(db, questId);
1240
+ // src/rumors/init_rumor_search.ts
1241
+ function initRumorSearch(db) {
1242
+ db.exec(`
1243
+ CREATE VIRTUAL TABLE IF NOT EXISTS rumors_fts USING fts5(
1244
+ title,
1245
+ details,
1246
+ content=rumors,
1247
+ content_rowid=id
1248
+ )
1249
+ `);
1250
+ db.exec(`
1251
+ CREATE TRIGGER IF NOT EXISTS rumors_fts_insert
1252
+ AFTER INSERT ON rumors
1253
+ WHEN new.deleted_at IS NULL
1254
+ BEGIN
1255
+ INSERT INTO rumors_fts(rowid, title, details)
1256
+ VALUES (new.id, new.title, COALESCE(new.details, ''));
1257
+ END
1258
+ `);
1259
+ db.exec(`
1260
+ CREATE TRIGGER IF NOT EXISTS rumors_fts_update
1261
+ AFTER UPDATE OF title, details, deleted_at ON rumors
1262
+ BEGIN
1263
+ INSERT INTO rumors_fts(rumors_fts, rowid, title, details)
1264
+ VALUES ('delete', old.id, old.title, COALESCE(old.details, ''));
1265
+ INSERT INTO rumors_fts(rowid, title, details)
1266
+ SELECT new.id, new.title, COALESCE(new.details, '')
1267
+ WHERE new.deleted_at IS NULL;
1268
+ END
1269
+ `);
1270
+ }
1271
+
1272
+ // src/rumors/init_rumor_tables.ts
1273
+ function initRumorTables(db) {
1274
+ db.exec(`
1275
+ CREATE TABLE IF NOT EXISTS rumors (
1276
+ id INTEGER PRIMARY KEY,
1277
+ title TEXT NOT NULL,
1278
+ details TEXT,
1279
+ created_at INTEGER NOT NULL,
1280
+ updated_at INTEGER NOT NULL,
1281
+ settled_at INTEGER,
1282
+ dismissed_at INTEGER,
1283
+ deleted_at INTEGER,
1284
+ CHECK(length(trim(title)) > 0),
1285
+ CHECK(settled_at IS NULL OR dismissed_at IS NULL)
1286
+ )
1287
+ `);
1288
+ db.exec(
1289
+ "CREATE INDEX IF NOT EXISTS idx_rumors_open ON rumors(settled_at, dismissed_at, deleted_at)"
1290
+ );
1291
+ db.exec(
1292
+ "CREATE INDEX IF NOT EXISTS idx_rumors_created_at_active ON rumors(created_at DESC, id DESC) WHERE deleted_at IS NULL"
1293
+ );
1294
+ }
1295
+
1296
+ // src/tags/init_tag_tables.ts
1297
+ function initTagTables(db) {
1298
+ db.exec(`
1299
+ CREATE TABLE IF NOT EXISTS tags (
1300
+ id INTEGER PRIMARY KEY,
1301
+ name TEXT NOT NULL,
1302
+ normalized_name TEXT NOT NULL,
1303
+ created_at INTEGER NOT NULL,
1304
+ deleted_at INTEGER,
1305
+ CHECK(length(trim(name)) > 0),
1306
+ CHECK(length(trim(normalized_name)) > 0)
1307
+ )
1308
+ `);
1309
+ db.exec(
1310
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_normalized_name_unique ON tags(normalized_name) WHERE deleted_at IS NULL"
1311
+ );
1312
+ db.exec(`
1313
+ CREATE TABLE IF NOT EXISTS quest_tags (
1314
+ quest_id INTEGER NOT NULL REFERENCES quests(id),
1315
+ tag_id INTEGER NOT NULL REFERENCES tags(id),
1316
+ created_at INTEGER NOT NULL,
1317
+ deleted_at INTEGER,
1318
+ PRIMARY KEY (quest_id, tag_id, created_at)
1319
+ )
1320
+ `);
1321
+ db.exec(
1322
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_quest_tags_unique ON quest_tags(quest_id, tag_id) WHERE deleted_at IS NULL"
1323
+ );
1324
+ db.exec(
1325
+ "CREATE INDEX IF NOT EXISTS idx_quest_tags_tag_id ON quest_tags(tag_id) WHERE deleted_at IS NULL"
1326
+ );
1327
+ db.exec(`
1328
+ CREATE TABLE IF NOT EXISTS repeatable_quest_tags (
1329
+ repeatable_quest_id INTEGER NOT NULL REFERENCES repeatable_quests(id),
1330
+ tag_id INTEGER NOT NULL REFERENCES tags(id),
1331
+ created_at INTEGER NOT NULL,
1332
+ deleted_at INTEGER,
1333
+ PRIMARY KEY (repeatable_quest_id, tag_id, created_at)
1334
+ )
1335
+ `);
1336
+ db.exec(
1337
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_repeatable_quest_tags_unique ON repeatable_quest_tags(repeatable_quest_id, tag_id) WHERE deleted_at IS NULL"
1338
+ );
1339
+ }
1340
+
1341
+ // src/unlocks/init_unlock_tables.ts
1342
+ function initUnlockTables(db) {
1343
+ db.exec(`
1344
+ CREATE TABLE IF NOT EXISTS quest_unlocks (
1345
+ id INTEGER PRIMARY KEY,
1346
+ from_quest_id INTEGER NOT NULL REFERENCES quests(id),
1347
+ to_quest_id INTEGER NOT NULL REFERENCES quests(id),
1348
+ created_at INTEGER NOT NULL,
1349
+ deleted_at INTEGER,
1350
+ CHECK(from_quest_id <> to_quest_id)
1351
+ )
1352
+ `);
1353
+ db.exec(
1354
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_quest_unlocks_pair_unique ON quest_unlocks(from_quest_id, to_quest_id) WHERE deleted_at IS NULL"
1355
+ );
1356
+ db.exec(
1357
+ "CREATE INDEX IF NOT EXISTS idx_quest_unlocks_to_quest_id ON quest_unlocks(to_quest_id) WHERE deleted_at IS NULL"
1358
+ );
1359
+ }
1360
+
1361
+ // src/init_questlog_tables.ts
1362
+ function initQuestlogTables(db) {
1363
+ initRumorTables(db);
1364
+ initQuestlineTables(db);
1365
+ initRepeatableQuestTables(db);
1366
+ initQuestTables(db);
1367
+ initUnlockTables(db);
1368
+ initRewardTables(db);
1369
+ initTagTables(db);
1370
+ initQuestSearch(db);
1371
+ initRumorSearch(db);
1372
+ initHookTables(db);
1373
+ }
1374
+
1375
+ // src/markers/index.ts
1376
+ var markers_exports = {};
1377
+ __export(markers_exports, {
1378
+ deriveQuestMarkerId: () => deriveQuestMarkerId,
1379
+ deriveRumorMarkerId: () => deriveRumorMarkerId,
1380
+ markerLookup: () => markerLookup
1381
+ });
1382
+
1383
+ // src/markers/derive_rumor_marker_id.ts
1384
+ function deriveRumorMarkerId({ state }) {
1385
+ if (state === "open") {
1386
+ return "attention.available";
1387
+ }
1388
+ return null;
1389
+ }
1390
+
1391
+ // src/markers/lookup.ts
1392
+ var markerLookup = {
1393
+ "attention.available": {
1394
+ id: "attention.available",
1395
+ family: "attention",
1396
+ symbol: "!",
1397
+ wowColor: "yellow",
1398
+ label: "Available",
1399
+ plainText: "! available",
1400
+ ttyText: "\x1B[33m!\x1B[39m",
1401
+ htmlClass: "questlog-marker--attention-available"
1402
+ },
1403
+ "attention.available.repeatable": {
1404
+ id: "attention.available.repeatable",
1405
+ family: "attention",
1406
+ symbol: "!",
1407
+ wowColor: "blue",
1408
+ label: "Repeatable available",
1409
+ plainText: "! repeatable",
1410
+ ttyText: "\x1B[34m!\x1B[39m",
1411
+ htmlClass: "questlog-marker--attention-available-repeatable"
1412
+ },
1413
+ "attention.available.future": {
1414
+ id: "attention.available.future",
1415
+ family: "attention",
1416
+ symbol: "!",
1417
+ wowColor: "gray",
1418
+ label: "Future available",
1419
+ plainText: "! future",
1420
+ ttyText: "\x1B[90m!\x1B[39m",
1421
+ htmlClass: "questlog-marker--attention-available-future"
1422
+ },
1423
+ "progress.incomplete": {
1424
+ id: "progress.incomplete",
1425
+ family: "progress",
1426
+ symbol: "?",
1427
+ wowColor: "gray",
1428
+ label: "Incomplete",
1429
+ plainText: "? incomplete",
1430
+ ttyText: "\x1B[90m?\x1B[39m",
1431
+ htmlClass: "questlog-marker--progress-incomplete"
1432
+ },
1433
+ "progress.complete": {
1434
+ id: "progress.complete",
1435
+ family: "progress",
1436
+ symbol: "?",
1437
+ wowColor: "yellow",
1438
+ label: "Turn-in pending",
1439
+ plainText: "? turn-in pending",
1440
+ ttyText: "\x1B[33m?\x1B[39m",
1441
+ htmlClass: "questlog-marker--progress-complete"
1442
+ }
1443
+ };
1444
+
1445
+ // src/read.ts
1446
+ var read_exports = {};
1447
+ __export(read_exports, {
1448
+ getQuestDetail: () => getQuestDetail,
1449
+ getQuestlineDetail: () => getQuestlineDetail,
1450
+ getRumorDetail: () => getRumorDetail,
1451
+ getRumorOutputs: () => getRumorOutputs,
1452
+ listActiveQuests: () => listActiveQuests,
1453
+ listAvailableQuests: () => listAvailableQuests,
1454
+ listBlockedQuests: () => listBlockedQuests,
1455
+ listDeferredQuests: () => listDeferredQuests,
1456
+ listDueRepeatableQuestAnchors: () => listDueRepeatableQuestAnchors,
1457
+ listDueSoonQuests: () => listDueSoonQuests,
1458
+ listInProgressQuests: () => listInProgressQuests,
1459
+ listMissedScheduledQuests: () => listMissedScheduledQuests,
1460
+ listOpenQuests: () => listOpenQuests,
1461
+ listOverdueQuests: () => listOverdueQuests,
1462
+ listQuestlines: () => listQuestlines,
1463
+ listResolvedQuests: () => listResolvedQuests,
1464
+ listRumors: () => listRumors,
1465
+ listScheduledForDay: () => listScheduledForDay,
1466
+ listScheduledNow: () => listScheduledNow,
1467
+ searchQuestlog: () => searchQuestlog
1468
+ });
1469
+
1470
+ // src/assert_non_empty.ts
1471
+ function assertNonEmpty(value, label) {
1472
+ const trimmed = value.trim();
1473
+ if (trimmed.length === 0) {
1474
+ throw new Error(`${label} must not be empty.`);
1475
+ }
1476
+ return trimmed;
1477
+ }
1478
+
1479
+ // src/questlines/map_questline_row.ts
1480
+ function mapQuestlineRow(row) {
1481
+ return {
1482
+ id: Number(row.id),
1483
+ sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
1484
+ title: String(row.title),
1485
+ description: row.description == null ? null : String(row.description),
1486
+ startsAt: row.starts_at == null ? null : Number(row.starts_at),
1487
+ dueAt: row.due_at == null ? null : Number(row.due_at),
1488
+ createdAt: Number(row.created_at),
1489
+ updatedAt: Number(row.updated_at),
1490
+ archivedAt: row.archived_at == null ? null : Number(row.archived_at),
1491
+ deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
1492
+ };
1493
+ }
1494
+
1495
+ // src/questlines/get_questline_or_throw.ts
1496
+ function getQuestlineOrThrow(db, questlineId) {
1497
+ const row = db.prepare("SELECT * FROM questlines WHERE id = ? AND deleted_at IS NULL").get(questlineId);
1498
+ if (!row) {
1499
+ throw new Error(`Questline ${questlineId} was not found.`);
1500
+ }
1501
+ return mapQuestlineRow(row);
1502
+ }
1503
+
1504
+ // src/questlines/update_questline.ts
1505
+ function updateQuestline(db, questlineId, input) {
1506
+ return withTransaction(db, () => {
1507
+ const current = getQuestlineOrThrow(db, questlineId);
1508
+ const now = resolveNow(input.now);
1509
+ db.prepare(
1510
+ `UPDATE questlines
1511
+ SET title = ?,
1512
+ description = ?,
1513
+ starts_at = ?,
1514
+ due_at = ?,
1515
+ archived_at = ?,
1516
+ updated_at = ?
1517
+ WHERE id = ?`
1518
+ ).run(
1519
+ input.title == null ? current.title : assertNonEmpty(input.title, "Questline title"),
1520
+ input.description === void 0 ? current.description : input.description?.trim() || null,
1521
+ input.startsAt === void 0 ? current.startsAt : input.startsAt,
1522
+ input.dueAt === void 0 ? current.dueAt : input.dueAt,
1523
+ input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
1524
+ now,
1525
+ questlineId
1526
+ );
1527
+ return getQuestlineOrThrow(db, questlineId);
1528
+ });
1529
+ }
1530
+
1531
+ // src/questlines/archive_questline.ts
1532
+ function archiveQuestline(db, questlineId, archivedAt) {
1533
+ return updateQuestline(db, questlineId, { archivedAt: resolveNow(archivedAt) });
1534
+ }
1535
+
1536
+ // src/assert_active_row_exists.ts
1537
+ function assertActiveRowExists(db, table, id, label) {
1538
+ const row = db.prepare(`SELECT id FROM ${table} WHERE id = ? AND deleted_at IS NULL`).get(id);
1539
+ if (!row) {
1540
+ throw new Error(`${label} ${id} was not found.`);
1541
+ }
1542
+ }
1543
+
1544
+ // src/questlines/create_questline.ts
1545
+ function createQuestline(db, input) {
1546
+ const now = resolveNow(input.now);
1547
+ const title = assertNonEmpty(input.title, "Questline title");
1548
+ if (input.sourceRumorId != null) {
1549
+ assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
1550
+ }
1551
+ const created = db.prepare(
1552
+ `INSERT INTO questlines (
1553
+ source_rumor_id,
1554
+ title,
1555
+ description,
1556
+ starts_at,
1557
+ due_at,
1558
+ created_at,
1559
+ updated_at
1560
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`
1561
+ ).run(
1562
+ input.sourceRumorId ?? null,
1563
+ title,
1564
+ input.description?.trim() || null,
1565
+ input.startsAt ?? null,
1566
+ input.dueAt ?? null,
1567
+ now,
1568
+ now
1569
+ );
1570
+ return getQuestlineOrThrow(db, Number(created.lastInsertRowid));
1571
+ }
1572
+
1573
+ // src/questlines/detach_quest_from_questline.ts
1574
+ function detachQuestFromQuestline(db, questId, now) {
1575
+ assertActiveRowExists(db, "quests", questId, "Quest");
1576
+ db.prepare(
1577
+ "UPDATE quests SET questline_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
1578
+ ).run(resolveNow(now), questId);
1579
+ }
1580
+
1581
+ // src/questlines/query_questline_stats.ts
1582
+ function queryQuestlineStats(db, now, questlineIds) {
1583
+ if (questlineIds && questlineIds.length === 0) {
1584
+ return /* @__PURE__ */ new Map();
1585
+ }
1586
+ const params = [now, now];
1587
+ const questlineFilter = questlineIds && questlineIds.length > 0 ? `AND q.questline_id IN (${questlineIds.map(() => "?").join(", ")})` : "";
1588
+ if (questlineIds && questlineIds.length > 0) {
1589
+ params.push(...questlineIds);
1590
+ }
1591
+ const rows = db.prepare(
1592
+ `SELECT q.questline_id,
1593
+ COUNT(*) AS total_quests,
1594
+ SUM(CASE WHEN q.started_at IS NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS open_quests,
1595
+ SUM(CASE WHEN q.started_at IS NOT NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS in_progress_quests,
1596
+ SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 1 THEN 1 ELSE 0 END) AS done_quests,
1597
+ SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 0 THEN 1 ELSE 0 END) AS abandoned_quests,
1598
+ SUM(
1599
+ CASE
1600
+ WHEN q.resolved_at IS NULL
1601
+ AND (q.not_before_at IS NULL OR q.not_before_at <= ?)
1602
+ AND NOT EXISTS (
1603
+ SELECT 1
1604
+ FROM quest_unlocks qu
1605
+ JOIN quests blocker ON blocker.id = qu.from_quest_id
1606
+ WHERE qu.to_quest_id = q.id
1607
+ AND qu.deleted_at IS NULL
1608
+ AND blocker.deleted_at IS NULL
1609
+ AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
1610
+ )
1611
+ THEN 1
1612
+ ELSE 0
1613
+ END
1614
+ ) AS available_quests,
1615
+ SUM(
1616
+ CASE
1617
+ WHEN q.resolved_at IS NULL
1618
+ AND COALESCE(q.due_at, ql.due_at) IS NOT NULL
1619
+ AND COALESCE(q.due_at, ql.due_at) < ?
1620
+ THEN 1
1621
+ ELSE 0
1622
+ END
1623
+ ) AS overdue_quests
1624
+ FROM quests q
1625
+ LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
1626
+ WHERE q.deleted_at IS NULL
1627
+ AND q.questline_id IS NOT NULL
1628
+ ${questlineFilter}
1629
+ GROUP BY q.questline_id`
1630
+ ).all(...params);
1631
+ return new Map(
1632
+ rows.map((row) => [
1633
+ Number(row.questline_id),
1634
+ {
1635
+ totalQuests: Number(row.total_quests ?? 0),
1636
+ openQuests: Number(row.open_quests ?? 0),
1637
+ inProgressQuests: Number(row.in_progress_quests ?? 0),
1638
+ doneQuests: Number(row.done_quests ?? 0),
1639
+ abandonedQuests: Number(row.abandoned_quests ?? 0),
1640
+ availableQuests: Number(row.available_quests ?? 0),
1641
+ overdueQuests: Number(row.overdue_quests ?? 0)
1642
+ }
1643
+ ])
1644
+ );
1321
1645
  }
1322
1646
 
1323
- // src/quests/plan_quest_time.ts
1324
- function planQuestTime(db, questId, input) {
1325
- const current = getQuestOrThrow(db, questId);
1326
- const now = resolveNow(input.now);
1327
- db.prepare(
1328
- `UPDATE quests
1329
- SET not_before_at = ?,
1330
- due_at = ?,
1331
- scheduled_start_at = ?,
1332
- scheduled_end_at = ?,
1333
- all_day = ?,
1334
- estimate_seconds = ?,
1335
- updated_at = ?
1336
- WHERE id = ? AND deleted_at IS NULL`
1337
- ).run(
1338
- input.notBeforeAt === void 0 ? current.notBeforeAt : input.notBeforeAt,
1339
- input.dueAt === void 0 ? current.dueAt : input.dueAt,
1340
- input.scheduledStartAt === void 0 ? current.scheduledStartAt : input.scheduledStartAt,
1341
- input.scheduledEndAt === void 0 ? current.scheduledEndAt : input.scheduledEndAt,
1342
- input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
1343
- input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate") ?? null,
1647
+ // src/questlines/get_questline_detail.ts
1648
+ function getQuestlineDetail(db, questlineId, now = Date.now()) {
1649
+ const questline = getQuestlineOrThrow(db, questlineId);
1650
+ const stats = queryQuestlineStats(db, now, [questlineId]).get(questlineId);
1651
+ return {
1652
+ ...questline,
1653
+ totalQuests: stats?.totalQuests ?? 0,
1654
+ openQuests: stats?.openQuests ?? 0,
1655
+ inProgressQuests: stats?.inProgressQuests ?? 0,
1656
+ doneQuests: stats?.doneQuests ?? 0,
1657
+ abandonedQuests: stats?.abandonedQuests ?? 0,
1658
+ availableQuests: stats?.availableQuests ?? 0,
1659
+ overdueQuests: stats?.overdueQuests ?? 0
1660
+ };
1661
+ }
1662
+
1663
+ // src/questlines/list_questlines.ts
1664
+ function listQuestlines(db, now = Date.now()) {
1665
+ const questlines = db.prepare("SELECT * FROM questlines WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC").all().map(mapQuestlineRow);
1666
+ if (questlines.length === 0) {
1667
+ return [];
1668
+ }
1669
+ const statsByQuestlineId = queryQuestlineStats(
1670
+ db,
1344
1671
  now,
1345
- questId
1672
+ questlines.map((questline) => questline.id)
1346
1673
  );
1347
- return getQuestOrThrow(db, questId);
1674
+ return questlines.map((questline) => {
1675
+ const stats = statsByQuestlineId.get(questline.id);
1676
+ return {
1677
+ ...questline,
1678
+ totalQuests: stats?.totalQuests ?? 0,
1679
+ openQuests: stats?.openQuests ?? 0,
1680
+ inProgressQuests: stats?.inProgressQuests ?? 0,
1681
+ doneQuests: stats?.doneQuests ?? 0,
1682
+ abandonedQuests: stats?.abandonedQuests ?? 0,
1683
+ availableQuests: stats?.availableQuests ?? 0,
1684
+ overdueQuests: stats?.overdueQuests ?? 0
1685
+ };
1686
+ });
1348
1687
  }
1349
1688
 
1350
- // src/quests/revise_quest_objective.ts
1351
- function reviseQuestObjective(db, questId, objective, now) {
1352
- const current = getQuestOrThrow(db, questId);
1353
- if (current.startedAt != null) {
1354
- throw new Error("Quest objective cannot change after the quest has actually started.");
1355
- }
1689
+ // src/questlines/move_quest_to_questline.ts
1690
+ function moveQuestToQuestline(db, questId, questlineId, now) {
1691
+ assertActiveRowExists(db, "quests", questId, "Quest");
1692
+ assertActiveRowExists(db, "questlines", questlineId, "Questline");
1356
1693
  db.prepare(
1357
- `UPDATE quests
1358
- SET objective = ?, updated_at = ?
1359
- WHERE id = ? AND deleted_at IS NULL`
1360
- ).run(assertNonEmpty(objective, "Quest objective"), resolveNow(now), questId);
1361
- return getQuestOrThrow(db, questId);
1694
+ "UPDATE quests SET questline_id = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
1695
+ ).run(questlineId, resolveNow(now), questId);
1362
1696
  }
1363
1697
 
1364
- // src/quests/soft_delete_quest.ts
1365
- function softDeleteQuest(db, questId, now) {
1366
- const deletedAt = resolveNow(now);
1367
- getQuestOrThrow(db, questId);
1698
+ // src/questlines/soft_delete_questline.ts
1699
+ function softDeleteQuestline(db, questlineId, deletedAt) {
1700
+ const now = resolveNow(deletedAt);
1701
+ assertActiveRowExists(db, "questlines", questlineId, "Questline");
1368
1702
  db.prepare(
1369
- `UPDATE quests
1370
- SET deleted_at = ?, updated_at = ?
1371
- WHERE id = ? AND deleted_at IS NULL`
1372
- ).run(deletedAt, deletedAt, questId);
1703
+ "UPDATE questlines SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
1704
+ ).run(now, now, questlineId);
1373
1705
  }
1374
1706
 
1375
- // src/quests/start_quest.ts
1376
- function startQuest(db, questId, startedAt) {
1707
+ // src/quests/resolve_quest.ts
1708
+ function resolveQuest(db, questId, success, outcome, resolvedAt) {
1377
1709
  const current = getQuestOrThrow(db, questId);
1378
- if (current.startedAt != null) {
1379
- return current;
1380
- }
1381
1710
  if (current.resolvedAt != null) {
1382
- throw new Error("Resolved quests cannot be started.");
1711
+ throw new Error("Resolved quests cannot be resolved again.");
1383
1712
  }
1384
- const now = resolveNow(startedAt);
1713
+ const now = resolveNow(resolvedAt);
1385
1714
  db.prepare(
1386
1715
  `UPDATE quests
1387
- SET started_at = ?, updated_at = ?
1716
+ SET outcome = ?,
1717
+ resolved_at = ?,
1718
+ success = ?,
1719
+ updated_at = ?
1388
1720
  WHERE id = ? AND deleted_at IS NULL`
1389
- ).run(now, now, questId);
1721
+ ).run(assertNonEmpty(outcome, "Quest outcome"), now, success ? 1 : 0, now, questId);
1390
1722
  return getQuestOrThrow(db, questId);
1391
1723
  }
1392
1724
 
1393
- // src/repeatable_quests/map_repeatable_quest_row.ts
1394
- function asBoolean2(value) {
1395
- return Number(value) === 1;
1396
- }
1397
- function mapRepeatableQuestRow(row) {
1398
- return {
1399
- id: Number(row.id),
1400
- questlineId: row.questline_id == null ? null : Number(row.questline_id),
1401
- title: String(row.title),
1402
- objective: String(row.objective),
1403
- rrule: String(row.rrule),
1404
- anchorAt: Number(row.anchor_at),
1405
- notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
1406
- dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
1407
- scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
1408
- scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
1409
- allDay: asBoolean2(row.all_day),
1410
- estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
1411
- createdAt: Number(row.created_at),
1412
- updatedAt: Number(row.updated_at),
1413
- archivedAt: row.archived_at == null ? null : Number(row.archived_at),
1414
- deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
1415
- };
1725
+ // src/quests/abandon_quest.ts
1726
+ function abandonQuest(db, questId, outcome, resolvedAt) {
1727
+ return resolveQuest(db, questId, false, outcome, resolvedAt);
1416
1728
  }
1417
1729
 
1418
- // src/repeatable_quests/get_repeatable_quest_or_throw.ts
1419
- function getRepeatableQuestOrThrow(db, repeatableQuestId) {
1420
- const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
1421
- if (!row) {
1422
- throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
1730
+ // src/unlocks/would_create_unlock_cycle.ts
1731
+ function wouldCreateUnlockCycle(db, fromQuestId, toQuestId) {
1732
+ const frontier = [toQuestId];
1733
+ const seen = /* @__PURE__ */ new Set();
1734
+ while (frontier.length > 0) {
1735
+ const current = frontier.pop();
1736
+ if (current === fromQuestId) {
1737
+ return true;
1738
+ }
1739
+ if (seen.has(current)) {
1740
+ continue;
1741
+ }
1742
+ seen.add(current);
1743
+ const rows = db.prepare(
1744
+ `SELECT to_quest_id
1745
+ FROM quest_unlocks
1746
+ WHERE from_quest_id = ? AND deleted_at IS NULL`
1747
+ ).all(current);
1748
+ for (const row of rows) {
1749
+ frontier.push(Number(row.to_quest_id));
1750
+ }
1423
1751
  }
1424
- return mapRepeatableQuestRow(row);
1752
+ return false;
1425
1753
  }
1426
1754
 
1427
- // src/repeatable_quests/parse_rrule.ts
1428
- var WEEKDAY = {
1429
- SU: 0,
1430
- MO: 1,
1431
- TU: 2,
1432
- WE: 3,
1433
- TH: 4,
1434
- FR: 5,
1435
- SA: 6
1436
- };
1437
- function parseRRule(rrule) {
1438
- const parts = Object.fromEntries(
1439
- rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
1440
- const [key, value] = part.split("=");
1441
- return [key, value];
1442
- })
1443
- );
1444
- const freq = parts.FREQ;
1445
- if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
1446
- throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
1447
- }
1448
- const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
1449
- if (!Number.isFinite(interval) || interval < 1) {
1450
- throw new Error("RRULE INTERVAL must be a positive integer.");
1451
- }
1452
- const count = parts.COUNT == null ? null : Number(parts.COUNT);
1453
- if (count != null && (!Number.isFinite(count) || count < 1)) {
1454
- throw new Error("RRULE COUNT must be a positive integer.");
1455
- }
1456
- const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
1457
- if (parts.UNTIL != null && Number.isNaN(until)) {
1458
- throw new Error("RRULE UNTIL must be parseable as a date.");
1459
- }
1460
- const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
1461
- const value = WEEKDAY[token];
1462
- if (value == null) {
1463
- throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
1464
- }
1465
- return value;
1466
- });
1467
- return { freq, interval, count, until, byDay };
1755
+ // src/unlocks/add_unlock.ts
1756
+ function addUnlock(db, fromQuestId, toQuestId, now) {
1757
+ assertActiveRowExists(db, "quests", fromQuestId, "Quest");
1758
+ assertActiveRowExists(db, "quests", toQuestId, "Quest");
1759
+ if (fromQuestId === toQuestId) {
1760
+ throw new Error("A quest cannot unlock itself.");
1761
+ }
1762
+ if (wouldCreateUnlockCycle(db, fromQuestId, toQuestId)) {
1763
+ throw new Error("Unlock would create a cycle.");
1764
+ }
1765
+ db.prepare(
1766
+ `INSERT INTO quest_unlocks (from_quest_id, to_quest_id, created_at)
1767
+ VALUES (?, ?, ?)`
1768
+ ).run(fromQuestId, toQuestId, resolveNow(now));
1468
1769
  }
1469
1770
 
1470
- // src/repeatable_quests/update_repeatable_quest.ts
1471
- function updateRepeatableQuest(db, repeatableQuestId, input) {
1472
- return withTransaction(db, () => {
1473
- const current = getRepeatableQuestOrThrow(db, repeatableQuestId);
1474
- const now = resolveNow(input.now);
1475
- const rrule = input.rrule ?? current.rrule;
1476
- parseRRule(rrule);
1477
- db.prepare(
1478
- `UPDATE repeatable_quests
1479
- SET questline_id = ?,
1480
- title = ?,
1481
- objective = ?,
1482
- rrule = ?,
1483
- anchor_at = ?,
1484
- not_before_offset_seconds = ?,
1485
- due_offset_seconds = ?,
1486
- scheduled_start_offset_seconds = ?,
1487
- scheduled_end_offset_seconds = ?,
1488
- all_day = ?,
1489
- estimate_seconds = ?,
1490
- archived_at = ?,
1491
- updated_at = ?
1492
- WHERE id = ? AND deleted_at IS NULL`
1493
- ).run(
1494
- input.questlineId === void 0 ? current.questlineId : input.questlineId,
1495
- input.title == null ? current.title : assertNonEmpty(input.title, "Repeatable quest title"),
1496
- input.objective == null ? current.objective : assertNonEmpty(input.objective, "Repeatable quest objective"),
1497
- rrule,
1498
- input.anchorAt ?? current.anchorAt,
1499
- input.notBeforeOffsetSeconds === void 0 ? current.notBeforeOffsetSeconds : assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
1500
- input.dueOffsetSeconds === void 0 ? current.dueOffsetSeconds : assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
1501
- input.scheduledStartOffsetSeconds === void 0 ? current.scheduledStartOffsetSeconds : input.scheduledStartOffsetSeconds,
1502
- input.scheduledEndOffsetSeconds === void 0 ? current.scheduledEndOffsetSeconds : input.scheduledEndOffsetSeconds,
1503
- input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
1504
- input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
1505
- input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
1506
- now,
1507
- repeatableQuestId
1508
- );
1509
- return getRepeatableQuestOrThrow(db, repeatableQuestId);
1510
- });
1771
+ // src/assert_non_negative_seconds.ts
1772
+ function assertNonNegativeSeconds(value, label) {
1773
+ if (value != null && value < 0) {
1774
+ throw new Error(`${label} must not be negative.`);
1775
+ }
1776
+ return value;
1511
1777
  }
1512
1778
 
1513
- // src/repeatable_quests/archive_repeatable_quest.ts
1514
- function archiveRepeatableQuest(db, repeatableQuestId, archivedAt) {
1515
- return updateRepeatableQuest(db, repeatableQuestId, { archivedAt: resolveNow(archivedAt) });
1779
+ // src/assert_non_negative_number.ts
1780
+ function assertNonNegativeNumber(value, label) {
1781
+ if (value != null && value < 0) {
1782
+ throw new Error(`${label} must not be negative.`);
1783
+ }
1784
+ return value;
1516
1785
  }
1517
1786
 
1518
- // src/rewards/replace_active_repeatable_quest_rewards.ts
1519
- function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
1787
+ // src/rewards/replace_active_quest_rewards.ts
1788
+ function replaceActiveQuestRewards(db, questId, rewards, now) {
1520
1789
  const effectiveNow = resolveNow(now);
1521
1790
  db.prepare(
1522
- "UPDATE repeatable_quest_rewards SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
1523
- ).run(effectiveNow, repeatableQuestId);
1791
+ "UPDATE quest_rewards SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL"
1792
+ ).run(effectiveNow, questId);
1524
1793
  for (const reward of rewards) {
1525
1794
  db.prepare(
1526
- `INSERT INTO repeatable_quest_rewards (
1527
- repeatable_quest_id,
1528
- kind,
1529
- name,
1530
- description,
1531
- quantity,
1532
- created_at
1533
- ) VALUES (?, ?, ?, ?, ?, ?)`
1795
+ `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
1796
+ VALUES (?, ?, ?, ?, ?, ?)`
1534
1797
  ).run(
1535
- repeatableQuestId,
1798
+ questId,
1536
1799
  assertNonEmpty(reward.kind, "Reward kind"),
1537
1800
  assertNonEmpty(reward.name, "Reward name"),
1538
1801
  reward.description?.trim() || null,
@@ -1542,8 +1805,8 @@ function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now
1542
1805
  }
1543
1806
  }
1544
1807
 
1545
- // src/tags/replace_active_repeatable_quest_tags.ts
1546
- function getOrCreateTagId2(db, name, now) {
1808
+ // src/tags/replace_active_quest_tags.ts
1809
+ function getOrCreateTagId(db, name, now) {
1547
1810
  const normalizedName = normalizeTagName(name);
1548
1811
  if (!normalizedName) {
1549
1812
  throw new Error("Tag name must not be empty.");
@@ -1558,305 +1821,474 @@ function getOrCreateTagId2(db, name, now) {
1558
1821
  ).run(name.trim(), normalizedName, now);
1559
1822
  return Number(created.lastInsertRowid);
1560
1823
  }
1561
- function replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
1824
+ function replaceActiveQuestTags(db, questId, tagNames, now) {
1562
1825
  const effectiveNow = resolveNow(now);
1563
- db.prepare(
1564
- "UPDATE repeatable_quest_tags SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
1565
- ).run(effectiveNow, repeatableQuestId);
1826
+ db.prepare("UPDATE quest_tags SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL").run(
1827
+ effectiveNow,
1828
+ questId
1829
+ );
1566
1830
  const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
1567
1831
  for (const name of uniqueNames) {
1568
- const tagId = getOrCreateTagId2(db, name, effectiveNow);
1832
+ const tagId = getOrCreateTagId(db, name, effectiveNow);
1569
1833
  db.prepare(
1570
- `INSERT INTO repeatable_quest_tags (repeatable_quest_id, tag_id, created_at)
1834
+ `INSERT INTO quest_tags (quest_id, tag_id, created_at)
1571
1835
  VALUES (?, ?, ?)`
1572
- ).run(repeatableQuestId, tagId, effectiveNow);
1836
+ ).run(questId, tagId, effectiveNow);
1573
1837
  }
1574
1838
  }
1575
1839
 
1576
- // src/repeatable_quests/create_repeatable_quest.ts
1577
- function createRepeatableQuest(db, input) {
1840
+ // src/quests/create_quest.ts
1841
+ function createQuest(db, input) {
1578
1842
  return withTransaction(db, () => {
1579
1843
  const now = resolveNow(input.now);
1580
- const title = assertNonEmpty(input.title, "Repeatable quest title");
1581
- const objective = assertNonEmpty(input.objective, "Repeatable quest objective");
1582
- parseRRule(input.rrule);
1844
+ const title = assertNonEmpty(input.title, "Quest title");
1845
+ const objective = assertNonEmpty(input.objective, "Quest objective");
1583
1846
  if (input.questlineId != null) {
1584
1847
  assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
1585
1848
  }
1849
+ if (input.sourceRumorId != null) {
1850
+ assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
1851
+ }
1852
+ if (input.spawnedByQuestId != null) {
1853
+ assertActiveRowExists(db, "quests", input.spawnedByQuestId, "Quest");
1854
+ }
1855
+ if (input.spawnedFromRepeatableId != null) {
1856
+ assertActiveRowExists(
1857
+ db,
1858
+ "repeatable_quests",
1859
+ input.spawnedFromRepeatableId,
1860
+ "Repeatable quest"
1861
+ );
1862
+ }
1863
+ assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate");
1586
1864
  const created = db.prepare(
1587
- `INSERT INTO repeatable_quests (
1865
+ `INSERT INTO quests (
1588
1866
  questline_id,
1867
+ source_rumor_id,
1868
+ spawned_by_quest_id,
1869
+ spawned_from_repeatable_id,
1870
+ spawned_for_at,
1589
1871
  title,
1590
1872
  objective,
1591
- rrule,
1592
- anchor_at,
1593
- not_before_offset_seconds,
1594
- due_offset_seconds,
1595
- scheduled_start_offset_seconds,
1596
- scheduled_end_offset_seconds,
1597
- all_day,
1598
- estimate_seconds,
1599
1873
  created_at,
1600
- updated_at
1601
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1874
+ updated_at,
1875
+ effort_seconds,
1876
+ estimate_seconds,
1877
+ not_before_at,
1878
+ due_at,
1879
+ scheduled_start_at,
1880
+ scheduled_end_at,
1881
+ all_day
1882
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
1602
1883
  ).run(
1603
1884
  input.questlineId ?? null,
1885
+ input.sourceRumorId ?? null,
1886
+ input.spawnedByQuestId ?? null,
1887
+ input.spawnedFromRepeatableId ?? null,
1888
+ input.spawnedForAt ?? null,
1604
1889
  title,
1605
1890
  objective,
1606
- input.rrule,
1607
- input.anchorAt,
1608
- assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
1609
- assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
1610
- input.scheduledStartOffsetSeconds ?? null,
1611
- input.scheduledEndOffsetSeconds ?? null,
1612
- input.allDay ? 1 : 0,
1613
- assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
1614
1891
  now,
1615
- now
1892
+ now,
1893
+ input.estimateSeconds ?? null,
1894
+ input.notBeforeAt ?? null,
1895
+ input.dueAt ?? null,
1896
+ input.scheduledStartAt ?? null,
1897
+ input.scheduledEndAt ?? null,
1898
+ input.allDay ? 1 : 0
1616
1899
  );
1617
- const repeatableQuestId = Number(created.lastInsertRowid);
1900
+ const questId = Number(created.lastInsertRowid);
1618
1901
  if (input.tags && input.tags.length > 0) {
1619
- replaceActiveRepeatableQuestTags(db, repeatableQuestId, input.tags, now);
1902
+ replaceActiveQuestTags(db, questId, input.tags, now);
1620
1903
  }
1621
1904
  if (input.rewards && input.rewards.length > 0) {
1622
- replaceActiveRepeatableQuestRewards(db, repeatableQuestId, input.rewards, now);
1905
+ replaceActiveQuestRewards(db, questId, input.rewards, now);
1623
1906
  }
1624
- return getRepeatableQuestOrThrow(db, repeatableQuestId);
1907
+ return getQuestOrThrow(db, questId);
1908
+ });
1909
+ }
1910
+
1911
+ // src/quests/abandon_quest_and_spawn_followups.ts
1912
+ function abandonQuestAndSpawnFollowups(db, questId, outcome, followups, resolvedAt) {
1913
+ return withTransaction(db, () => {
1914
+ const abandonedQuest = abandonQuest(db, questId, outcome, resolvedAt);
1915
+ const now = resolveNow(resolvedAt);
1916
+ const followupQuests = followups.map(
1917
+ (followup) => createQuest(db, {
1918
+ ...followup,
1919
+ questlineId: followup.questlineId ?? abandonedQuest.questlineId,
1920
+ spawnedByQuestId: questId,
1921
+ now
1922
+ })
1923
+ );
1924
+ for (let i = 0; i < followups.length; i += 1) {
1925
+ const followup = followups[i];
1926
+ const created = followupQuests[i];
1927
+ if (followup?.unlocksParent && created) {
1928
+ addUnlock(db, created.id, questId, resolvedAt);
1929
+ }
1930
+ }
1931
+ return { abandonedQuest, followupQuests };
1625
1932
  });
1626
1933
  }
1627
1934
 
1628
- // src/repeatable_quests/advance_anchor.ts
1629
- var WEEK_MS = 7 * 864e5;
1630
- function advanceAnchor(anchorAt, freq, interval) {
1631
- const date = new Date(anchorAt);
1632
- if (freq === "MONTHLY") {
1633
- date.setUTCMonth(date.getUTCMonth() + interval);
1634
- return date.getTime();
1635
- }
1636
- if (freq === "YEARLY") {
1637
- date.setUTCFullYear(date.getUTCFullYear() + interval);
1638
- return date.getTime();
1639
- }
1640
- return anchorAt + interval * WEEK_MS;
1935
+ // src/quests/finish_quest.ts
1936
+ function finishQuest(db, questId, outcome, resolvedAt) {
1937
+ return resolveQuest(db, questId, true, outcome, resolvedAt);
1938
+ }
1939
+
1940
+ // src/quests/get_quest_detail.ts
1941
+ function getQuestDetail(db, questId, now = Date.now()) {
1942
+ const [record] = queryQuestDetailRecords(db, {}, now, [questId]);
1943
+ if (!record) {
1944
+ throw new Error(`Quest ${questId} was not found.`);
1945
+ }
1946
+ return record.detail;
1947
+ }
1948
+
1949
+ // src/quests/list_quest_details.ts
1950
+ function listQuestDetails(db, filters = {}, now = Date.now()) {
1951
+ return queryQuestDetailRecords(db, filters, now).map((record) => record.detail);
1952
+ }
1953
+
1954
+ // src/quests/list_active_quests.ts
1955
+ function listActiveQuests(db, filters = {}, now = Date.now()) {
1956
+ return listQuestDetails(db, filters, now).filter(
1957
+ (quest) => quest.state === "open" || quest.state === "in_progress"
1958
+ );
1959
+ }
1960
+
1961
+ // src/quests/list_available_quests.ts
1962
+ function listAvailableQuests(db, filters = {}, now = Date.now()) {
1963
+ return listQuestDetails(db, filters, now).filter((quest) => quest.available);
1964
+ }
1965
+
1966
+ // src/quests/list_blocked_quests.ts
1967
+ function listBlockedQuests(db, filters = {}, now = Date.now()) {
1968
+ return queryQuestDetailRecords(db, filters, now).filter(
1969
+ (record) => (record.detail.state === "open" || record.detail.state === "in_progress") && record.blockerCount > 0
1970
+ ).map((record) => record.detail);
1971
+ }
1972
+
1973
+ // src/quests/list_deferred_quests.ts
1974
+ function listDeferredQuests(db, filters = {}, now = Date.now()) {
1975
+ return listQuestDetails(db, filters, now).filter(
1976
+ (quest) => quest.resolvedAt == null && quest.notBeforeAt != null && quest.notBeforeAt > now
1977
+ );
1978
+ }
1979
+
1980
+ // src/quests/list_due_soon_quests.ts
1981
+ function listDueSoonQuests(db, horizonMs, filters = {}, now = Date.now()) {
1982
+ return listQuestDetails(db, filters, now).filter(
1983
+ (quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt >= now && quest.effectiveDueAt <= now + horizonMs
1984
+ );
1985
+ }
1986
+
1987
+ // src/quests/list_in_progress_quests.ts
1988
+ function listInProgressQuests(db, filters = {}, now = Date.now()) {
1989
+ return listQuestDetails(db, filters, now).filter((quest) => quest.state === "in_progress");
1641
1990
  }
1642
1991
 
1643
- // src/repeatable_quests/start_of_utc_day.ts
1644
- function startOfUtcDay(timestamp) {
1645
- const date = new Date(timestamp);
1646
- return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
1992
+ // src/quests/list_missed_scheduled_quests.ts
1993
+ function listMissedScheduledQuests(db, filters = {}, now = Date.now()) {
1994
+ return listQuestDetails(db, filters, now).filter(
1995
+ (quest) => quest.resolvedAt == null && quest.scheduledEndAt != null && quest.scheduledEndAt < now
1996
+ );
1647
1997
  }
1648
1998
 
1649
- // src/repeatable_quests/start_of_utc_week.ts
1650
- var DAY_MS = 864e5;
1651
- function startOfUtcWeek(dayStart) {
1652
- const date = new Date(dayStart);
1653
- return dayStart - date.getUTCDay() * DAY_MS;
1999
+ // src/quests/list_open_quests.ts
2000
+ function listOpenQuests(db, filters = {}, now = Date.now()) {
2001
+ return listQuestDetails(db, filters, now).filter((quest) => quest.state === "open");
1654
2002
  }
1655
2003
 
1656
- // src/repeatable_quests/generate_anchors_until.ts
1657
- var DAY_MS2 = 864e5;
1658
- var WEEK_MS2 = 7 * DAY_MS2;
1659
- function generateAnchorsUntil(repeatable, now) {
1660
- const rule = parseRRule(repeatable.rrule);
1661
- const anchors = [];
1662
- const until = rule.until == null ? now : Math.min(now, rule.until);
1663
- if (repeatable.anchorAt > until) {
1664
- return anchors;
1665
- }
1666
- if (rule.freq === "DAILY") {
1667
- let occurrence2 = 0;
1668
- for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
1669
- occurrence2 += 1;
1670
- if (rule.count != null && occurrence2 > rule.count) {
1671
- break;
1672
- }
1673
- anchors.push(current2);
1674
- }
1675
- return anchors;
1676
- }
1677
- if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
1678
- const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
1679
- const anchorWeekStart = startOfUtcWeek(anchorDayStart);
1680
- const timeOffset = repeatable.anchorAt - anchorDayStart;
1681
- let occurrence2 = 0;
1682
- for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
1683
- const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
1684
- if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
1685
- continue;
1686
- }
1687
- if (!rule.byDay.includes(new Date(day).getUTCDay())) {
1688
- continue;
1689
- }
1690
- const anchor = day + timeOffset;
1691
- if (anchor < repeatable.anchorAt || anchor > until) {
1692
- continue;
1693
- }
1694
- occurrence2 += 1;
1695
- if (rule.count != null && occurrence2 > rule.count) {
1696
- break;
1697
- }
1698
- anchors.push(anchor);
1699
- }
1700
- return anchors;
1701
- }
1702
- let occurrence = 0;
1703
- let current = repeatable.anchorAt;
1704
- while (current <= until) {
1705
- occurrence += 1;
1706
- if (rule.count != null && occurrence > rule.count) {
1707
- break;
1708
- }
1709
- anchors.push(current);
1710
- current = advanceAnchor(current, rule.freq, rule.interval);
1711
- }
1712
- return anchors;
2004
+ // src/quests/list_overdue_quests.ts
2005
+ function listOverdueQuests(db, filters = {}, now = Date.now()) {
2006
+ return listQuestDetails(db, filters, now).filter(
2007
+ (quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt < now
2008
+ );
1713
2009
  }
1714
2010
 
1715
- // src/repeatable_quests/list_due_repeatable_quest_anchors.ts
1716
- function listDueRepeatableQuestAnchors(db, now) {
1717
- const repeatables = db.prepare(
1718
- `SELECT *
1719
- FROM repeatable_quests
1720
- WHERE deleted_at IS NULL
1721
- AND archived_at IS NULL`
1722
- ).all().map(mapRepeatableQuestRow);
1723
- const anchors = [];
1724
- if (repeatables.length === 0) {
1725
- return anchors;
2011
+ // src/quests/list_resolved_quests.ts
2012
+ function listResolvedQuests(db, filters = {}, now = Date.now()) {
2013
+ return listQuestDetails(db, filters, now).filter((quest) => quest.resolvedAt != null);
2014
+ }
2015
+
2016
+ // src/quests/overlaps_schedule_window.ts
2017
+ function overlapsScheduleWindow(start, end, windowStart, windowEnd) {
2018
+ if (start == null) {
2019
+ return false;
1726
2020
  }
1727
- const repeatableIds = repeatables.map((repeatable) => repeatable.id);
1728
- const maxAnchorAt = Math.max(
1729
- ...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
2021
+ const effectiveEnd = end ?? start;
2022
+ return start < windowEnd && effectiveEnd >= windowStart;
2023
+ }
2024
+
2025
+ // src/quests/list_scheduled_for_day.ts
2026
+ function listScheduledForDay(db, dayStart, dayEnd, filters = {}, now = Date.now()) {
2027
+ return listQuestDetails(db, filters, now).filter(
2028
+ (quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, dayStart, dayEnd)
1730
2029
  );
1731
- const existingRows = db.prepare(
1732
- `SELECT spawned_from_repeatable_id, spawned_for_at
1733
- FROM quests
1734
- WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
1735
- AND spawned_for_at IS NOT NULL
1736
- AND spawned_for_at <= ?`
1737
- ).all(...repeatableIds, maxAnchorAt);
1738
- const existingAnchors = new Set(
1739
- existingRows.map(
1740
- (row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
1741
- )
2030
+ }
2031
+
2032
+ // src/quests/list_scheduled_now.ts
2033
+ function listScheduledNow(db, filters = {}, now = Date.now()) {
2034
+ return listQuestDetails(db, filters, now).filter(
2035
+ (quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, now, now + 1)
1742
2036
  );
1743
- for (const repeatable of repeatables) {
1744
- const dueAnchors = generateAnchorsUntil(repeatable, now);
1745
- for (const anchorAt of dueAnchors) {
1746
- if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
1747
- anchors.push({
1748
- repeatableQuestId: repeatable.id,
1749
- anchorAt,
1750
- markerId: "attention.available.repeatable"
1751
- });
1752
- }
1753
- }
2037
+ }
2038
+
2039
+ // src/quests/log_quest_effort.ts
2040
+ function logQuestEffort(db, questId, effortSeconds, now) {
2041
+ if (effortSeconds <= 0) {
2042
+ throw new Error("Quest effort increment must be greater than zero.");
1754
2043
  }
1755
- return anchors.sort(
1756
- (a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
2044
+ getQuestOrThrow(db, questId);
2045
+ db.prepare(
2046
+ `UPDATE quests
2047
+ SET effort_seconds = effort_seconds + ?, updated_at = ?
2048
+ WHERE id = ? AND deleted_at IS NULL`
2049
+ ).run(effortSeconds, resolveNow(now), questId);
2050
+ return getQuestOrThrow(db, questId);
2051
+ }
2052
+
2053
+ // src/quests/plan_quest_time.ts
2054
+ function planQuestTime(db, questId, input) {
2055
+ const current = getQuestOrThrow(db, questId);
2056
+ const now = resolveNow(input.now);
2057
+ db.prepare(
2058
+ `UPDATE quests
2059
+ SET not_before_at = ?,
2060
+ due_at = ?,
2061
+ scheduled_start_at = ?,
2062
+ scheduled_end_at = ?,
2063
+ all_day = ?,
2064
+ estimate_seconds = ?,
2065
+ updated_at = ?
2066
+ WHERE id = ? AND deleted_at IS NULL`
2067
+ ).run(
2068
+ input.notBeforeAt === void 0 ? current.notBeforeAt : input.notBeforeAt,
2069
+ input.dueAt === void 0 ? current.dueAt : input.dueAt,
2070
+ input.scheduledStartAt === void 0 ? current.scheduledStartAt : input.scheduledStartAt,
2071
+ input.scheduledEndAt === void 0 ? current.scheduledEndAt : input.scheduledEndAt,
2072
+ input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
2073
+ input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate") ?? null,
2074
+ now,
2075
+ questId
1757
2076
  );
2077
+ return getQuestOrThrow(db, questId);
2078
+ }
2079
+
2080
+ // src/quests/revise_quest_objective.ts
2081
+ function reviseQuestObjective(db, questId, objective, now) {
2082
+ const current = getQuestOrThrow(db, questId);
2083
+ if (current.startedAt != null) {
2084
+ throw new Error("Quest objective cannot change after the quest has actually started.");
2085
+ }
2086
+ db.prepare(
2087
+ `UPDATE quests
2088
+ SET objective = ?, updated_at = ?
2089
+ WHERE id = ? AND deleted_at IS NULL`
2090
+ ).run(assertNonEmpty(objective, "Quest objective"), resolveNow(now), questId);
2091
+ return getQuestOrThrow(db, questId);
2092
+ }
2093
+
2094
+ // src/quests/soft_delete_quest.ts
2095
+ function softDeleteQuest(db, questId, now) {
2096
+ const deletedAt = resolveNow(now);
2097
+ getQuestOrThrow(db, questId);
2098
+ db.prepare(
2099
+ `UPDATE quests
2100
+ SET deleted_at = ?, updated_at = ?
2101
+ WHERE id = ? AND deleted_at IS NULL`
2102
+ ).run(deletedAt, deletedAt, questId);
2103
+ }
2104
+
2105
+ // src/quests/start_quest.ts
2106
+ function startQuest(db, questId, startedAt) {
2107
+ const current = getQuestOrThrow(db, questId);
2108
+ if (current.startedAt != null) {
2109
+ return current;
2110
+ }
2111
+ if (current.resolvedAt != null) {
2112
+ throw new Error("Resolved quests cannot be started.");
2113
+ }
2114
+ const now = resolveNow(startedAt);
2115
+ db.prepare(
2116
+ `UPDATE quests
2117
+ SET started_at = ?, updated_at = ?
2118
+ WHERE id = ? AND deleted_at IS NULL`
2119
+ ).run(now, now, questId);
2120
+ return getQuestOrThrow(db, questId);
2121
+ }
2122
+
2123
+ // src/repeatable_quests/update_repeatable_quest.ts
2124
+ function updateRepeatableQuest(db, repeatableQuestId, input) {
2125
+ return withTransaction(db, () => {
2126
+ const current = getRepeatableQuestOrThrow(db, repeatableQuestId);
2127
+ const now = resolveNow(input.now);
2128
+ const rrule = input.rrule ?? current.rrule;
2129
+ parseRRule(rrule);
2130
+ db.prepare(
2131
+ `UPDATE repeatable_quests
2132
+ SET questline_id = ?,
2133
+ title = ?,
2134
+ objective = ?,
2135
+ rrule = ?,
2136
+ anchor_at = ?,
2137
+ not_before_offset_seconds = ?,
2138
+ due_offset_seconds = ?,
2139
+ scheduled_start_offset_seconds = ?,
2140
+ scheduled_end_offset_seconds = ?,
2141
+ all_day = ?,
2142
+ estimate_seconds = ?,
2143
+ archived_at = ?,
2144
+ updated_at = ?
2145
+ WHERE id = ? AND deleted_at IS NULL`
2146
+ ).run(
2147
+ input.questlineId === void 0 ? current.questlineId : input.questlineId,
2148
+ input.title == null ? current.title : assertNonEmpty(input.title, "Repeatable quest title"),
2149
+ input.objective == null ? current.objective : assertNonEmpty(input.objective, "Repeatable quest objective"),
2150
+ rrule,
2151
+ input.anchorAt ?? current.anchorAt,
2152
+ input.notBeforeOffsetSeconds === void 0 ? current.notBeforeOffsetSeconds : assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
2153
+ input.dueOffsetSeconds === void 0 ? current.dueOffsetSeconds : assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
2154
+ input.scheduledStartOffsetSeconds === void 0 ? current.scheduledStartOffsetSeconds : input.scheduledStartOffsetSeconds,
2155
+ input.scheduledEndOffsetSeconds === void 0 ? current.scheduledEndOffsetSeconds : input.scheduledEndOffsetSeconds,
2156
+ input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
2157
+ input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
2158
+ input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
2159
+ now,
2160
+ repeatableQuestId
2161
+ );
2162
+ return getRepeatableQuestOrThrow(db, repeatableQuestId);
2163
+ });
1758
2164
  }
1759
2165
 
1760
- // src/repeatable_quests/soft_delete_repeatable_quest.ts
1761
- function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
1762
- const now = resolveNow(deletedAt);
1763
- getRepeatableQuestOrThrow(db, repeatableQuestId);
1764
- db.prepare(
1765
- `UPDATE repeatable_quests
1766
- SET deleted_at = ?, updated_at = ?
1767
- WHERE id = ? AND deleted_at IS NULL`
1768
- ).run(now, now, repeatableQuestId);
2166
+ // src/repeatable_quests/archive_repeatable_quest.ts
2167
+ function archiveRepeatableQuest(db, repeatableQuestId, archivedAt) {
2168
+ return updateRepeatableQuest(db, repeatableQuestId, { archivedAt: resolveNow(archivedAt) });
1769
2169
  }
1770
2170
 
1771
- // src/rewards/copy_repeatable_quest_rewards_to_quest.ts
1772
- function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
2171
+ // src/rewards/replace_active_repeatable_quest_rewards.ts
2172
+ function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
1773
2173
  const effectiveNow = resolveNow(now);
1774
- const rows = db.prepare(
1775
- `SELECT kind, name, description, quantity
1776
- FROM repeatable_quest_rewards
1777
- WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1778
- ).all(repeatableQuestId);
1779
- for (const row of rows) {
2174
+ db.prepare(
2175
+ "UPDATE repeatable_quest_rewards SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
2176
+ ).run(effectiveNow, repeatableQuestId);
2177
+ for (const reward of rewards) {
1780
2178
  db.prepare(
1781
- `INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
1782
- VALUES (?, ?, ?, ?, ?, ?)`
2179
+ `INSERT INTO repeatable_quest_rewards (
2180
+ repeatable_quest_id,
2181
+ kind,
2182
+ name,
2183
+ description,
2184
+ quantity,
2185
+ created_at
2186
+ ) VALUES (?, ?, ?, ?, ?, ?)`
1783
2187
  ).run(
1784
- questId,
1785
- String(row.kind),
1786
- String(row.name),
1787
- row.description == null ? null : String(row.description),
1788
- row.quantity == null ? null : Number(row.quantity),
2188
+ repeatableQuestId,
2189
+ assertNonEmpty(reward.kind, "Reward kind"),
2190
+ assertNonEmpty(reward.name, "Reward name"),
2191
+ reward.description?.trim() || null,
2192
+ assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
1789
2193
  effectiveNow
1790
2194
  );
1791
2195
  }
1792
2196
  }
1793
2197
 
1794
- // src/tags/copy_repeatable_quest_tags_to_quest.ts
1795
- function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
2198
+ // src/tags/replace_active_repeatable_quest_tags.ts
2199
+ function getOrCreateTagId2(db, name, now) {
2200
+ const normalizedName = normalizeTagName(name);
2201
+ if (!normalizedName) {
2202
+ throw new Error("Tag name must not be empty.");
2203
+ }
2204
+ const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
2205
+ if (existing) {
2206
+ return Number(existing.id);
2207
+ }
2208
+ const created = db.prepare(
2209
+ `INSERT INTO tags (name, normalized_name, created_at)
2210
+ VALUES (?, ?, ?)`
2211
+ ).run(name.trim(), normalizedName, now);
2212
+ return Number(created.lastInsertRowid);
2213
+ }
2214
+ function replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
1796
2215
  const effectiveNow = resolveNow(now);
1797
- const rows = db.prepare(
1798
- `SELECT tag_id
1799
- FROM repeatable_quest_tags
1800
- WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
1801
- ).all(repeatableQuestId);
1802
- for (const row of rows) {
2216
+ db.prepare(
2217
+ "UPDATE repeatable_quest_tags SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
2218
+ ).run(effectiveNow, repeatableQuestId);
2219
+ const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
2220
+ for (const name of uniqueNames) {
2221
+ const tagId = getOrCreateTagId2(db, name, effectiveNow);
1803
2222
  db.prepare(
1804
- `INSERT INTO quest_tags (quest_id, tag_id, created_at)
2223
+ `INSERT INTO repeatable_quest_tags (repeatable_quest_id, tag_id, created_at)
1805
2224
  VALUES (?, ?, ?)`
1806
- ).run(questId, Number(row.tag_id), effectiveNow);
2225
+ ).run(repeatableQuestId, tagId, effectiveNow);
1807
2226
  }
1808
2227
  }
1809
2228
 
1810
- // src/repeatable_quests/spawn_due_repeatable_quests.ts
1811
- function spawnDueRepeatableQuests(db, now) {
2229
+ // src/repeatable_quests/create_repeatable_quest.ts
2230
+ function createRepeatableQuest(db, input) {
1812
2231
  return withTransaction(db, () => {
1813
- const anchors = listDueRepeatableQuestAnchors(db, now);
1814
- const quests = [];
1815
- for (const anchor of anchors) {
1816
- const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
1817
- const created = db.prepare(
1818
- `INSERT INTO quests (
1819
- questline_id,
1820
- source_rumor_id,
1821
- spawned_by_quest_id,
1822
- spawned_from_repeatable_id,
1823
- spawned_for_at,
1824
- title,
1825
- objective,
1826
- created_at,
1827
- updated_at,
1828
- effort_seconds,
1829
- estimate_seconds,
1830
- not_before_at,
1831
- due_at,
1832
- scheduled_start_at,
1833
- scheduled_end_at,
1834
- all_day
1835
- ) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
1836
- ).run(
1837
- repeatable.questlineId ?? null,
1838
- repeatable.id,
1839
- anchor.anchorAt,
1840
- repeatable.title,
1841
- repeatable.objective,
1842
- now,
1843
- now,
1844
- repeatable.estimateSeconds ?? null,
1845
- repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
1846
- repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
1847
- repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
1848
- repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
1849
- repeatable.allDay ? 1 : 0
1850
- );
1851
- const questId = Number(created.lastInsertRowid);
1852
- copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
1853
- copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
1854
- quests.push(getQuestOrThrow(db, questId));
2232
+ const now = resolveNow(input.now);
2233
+ const title = assertNonEmpty(input.title, "Repeatable quest title");
2234
+ const objective = assertNonEmpty(input.objective, "Repeatable quest objective");
2235
+ parseRRule(input.rrule);
2236
+ if (input.questlineId != null) {
2237
+ assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
1855
2238
  }
1856
- return quests;
2239
+ const created = db.prepare(
2240
+ `INSERT INTO repeatable_quests (
2241
+ questline_id,
2242
+ title,
2243
+ objective,
2244
+ rrule,
2245
+ anchor_at,
2246
+ not_before_offset_seconds,
2247
+ due_offset_seconds,
2248
+ scheduled_start_offset_seconds,
2249
+ scheduled_end_offset_seconds,
2250
+ all_day,
2251
+ estimate_seconds,
2252
+ created_at,
2253
+ updated_at
2254
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2255
+ ).run(
2256
+ input.questlineId ?? null,
2257
+ title,
2258
+ objective,
2259
+ input.rrule,
2260
+ input.anchorAt,
2261
+ assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
2262
+ assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
2263
+ input.scheduledStartOffsetSeconds ?? null,
2264
+ input.scheduledEndOffsetSeconds ?? null,
2265
+ input.allDay ? 1 : 0,
2266
+ assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
2267
+ now,
2268
+ now
2269
+ );
2270
+ const repeatableQuestId = Number(created.lastInsertRowid);
2271
+ if (input.tags && input.tags.length > 0) {
2272
+ replaceActiveRepeatableQuestTags(db, repeatableQuestId, input.tags, now);
2273
+ }
2274
+ if (input.rewards && input.rewards.length > 0) {
2275
+ replaceActiveRepeatableQuestRewards(db, repeatableQuestId, input.rewards, now);
2276
+ }
2277
+ return getRepeatableQuestOrThrow(db, repeatableQuestId);
1857
2278
  });
1858
2279
  }
1859
2280
 
2281
+ // src/repeatable_quests/soft_delete_repeatable_quest.ts
2282
+ function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
2283
+ const now = resolveNow(deletedAt);
2284
+ getRepeatableQuestOrThrow(db, repeatableQuestId);
2285
+ db.prepare(
2286
+ `UPDATE repeatable_quests
2287
+ SET deleted_at = ?, updated_at = ?
2288
+ WHERE id = ? AND deleted_at IS NULL`
2289
+ ).run(now, now, repeatableQuestId);
2290
+ }
2291
+
1860
2292
  // src/rumors/derive_rumor_state.ts
1861
2293
  function deriveRumorState(rumor) {
1862
2294
  if (rumor.dismissedAt != null) {
@@ -2862,8 +3294,16 @@ Failure handling:
2862
3294
  - Do not filter by tags and assume that is the same as a schedule or dependency review.`
2863
3295
  });
2864
3296
 
3297
+ // src/skills/webhooks.ts
3298
+ var webhooksSkill = defineQuestlogSkill({
3299
+ name: "webhooks",
3300
+ description: "Configure signed outbound event webhooks and verify deliveries.",
3301
+ 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.\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'
3302
+ });
3303
+
2865
3304
  // src/skills/skill_registry.ts
2866
3305
  var questlogSkills = [
3306
+ webhooksSkill,
2867
3307
  archiveDeleteAndPruneSkill,
2868
3308
  chooseWorkShapeSkill,
2869
3309
  clarifyAmbiguousRequestsSkill,
@@ -2969,6 +3409,8 @@ __export(tools_exports, {
2969
3409
  integerSchema: () => integerSchema,
2970
3410
  listQuestlogToolDefinitions: () => listQuestlogToolDefinitions,
2971
3411
  literalSchema: () => literalSchema,
3412
+ manageHooksTool: () => manageHooksTool,
3413
+ manageHooksToolName: () => manageHooksToolName,
2972
3414
  manageRepeatableTool: () => manageRepeatableTool,
2973
3415
  manageRepeatableToolHandler: () => manageRepeatableToolHandler,
2974
3416
  manageRepeatableToolName: () => manageRepeatableToolName,
@@ -3025,6 +3467,7 @@ var manageRepeatableToolName = "manage_repeatable";
3025
3467
  var tagWorkToolName = "tag_work";
3026
3468
  var rewardWorkToolName = "reward_work";
3027
3469
  var retireWorkToolName = "retire_work";
3470
+ var manageHooksToolName = "manage_hooks";
3028
3471
 
3029
3472
  // src/tools/tool_types.ts
3030
3473
  function withOptionalFields(result, options) {
@@ -3181,8 +3624,154 @@ function translateToolError(error, options = {}) {
3181
3624
  );
3182
3625
  }
3183
3626
 
3627
+ // src/surfaces/schema_actions.ts
3628
+ var requirements = /* @__PURE__ */ new WeakMap();
3629
+ function registerSchemaActions(schema, actions) {
3630
+ requirements.set(schema, actions);
3631
+ }
3632
+ function requiredForAction(schema, action) {
3633
+ return requirements.get(schema)?.[String(action)] ?? [];
3634
+ }
3635
+
3636
+ // src/surfaces/schema_examples.ts
3637
+ function minimalInput(schema, action, name = "") {
3638
+ const constant = "const" in schema ? schema.const : void 0;
3639
+ if (constant !== void 0) return constant;
3640
+ if (schema.enum)
3641
+ return action !== void 0 && schema.enum.includes(action) ? action : schema.enum[0];
3642
+ const branch = schema.oneOf?.[0];
3643
+ if (schema.type === "object" || schema.properties) {
3644
+ action ??= schema.properties?.action?.enum?.[0];
3645
+ const properties = { ...schema.properties, ...branch?.properties };
3646
+ return Object.fromEntries(
3647
+ [
3648
+ .../* @__PURE__ */ new Set([
3649
+ ...schema.required ?? [],
3650
+ ...requiredForAction(schema, action),
3651
+ ...schema.properties?.outcome && ["finish", "abandon", "abandon_and_spawn_followups"].includes(String(action)) ? ["outcome"] : [],
3652
+ ...branch?.required ?? []
3653
+ ])
3654
+ ].map((key) => [
3655
+ key,
3656
+ minimalInput(properties[key] ?? {}, key === "action" ? action : void 0, key)
3657
+ ])
3658
+ );
3659
+ }
3660
+ if (branch) return minimalInput(branch, action, name);
3661
+ if (schema.type === "array") return [minimalInput(schema.items ?? {}, void 0, name)];
3662
+ if (schema.type === "integer" || schema.type === "number")
3663
+ return name === "anchorAt" ? 18e11 : name === "toQuestId" ? 2 : 1;
3664
+ if (schema.type === "boolean") return false;
3665
+ if (schema.type === "null") return null;
3666
+ if (name === "rrule") return "FREQ=WEEKLY";
3667
+ if (name === "title") return "Review release notes";
3668
+ if (name === "objective") return "Check changes against the release";
3669
+ return "release";
3670
+ }
3671
+
3672
+ // src/surfaces/validate_input.ts
3673
+ function validateInput(schema, input, path = "$") {
3674
+ const errors = [];
3675
+ const matchesType = (type) => {
3676
+ switch (type) {
3677
+ case "object":
3678
+ return typeof input === "object" && input !== null && !Array.isArray(input);
3679
+ case "array":
3680
+ return Array.isArray(input);
3681
+ case "integer":
3682
+ return typeof input === "number" && Number.isInteger(input);
3683
+ case "number":
3684
+ return typeof input === "number" && Number.isFinite(input);
3685
+ case "null":
3686
+ return input === null;
3687
+ default:
3688
+ return typeof input === type;
3689
+ }
3690
+ };
3691
+ if (schema.type && !(Array.isArray(schema.type) ? schema.type : [schema.type]).some(matchesType)) {
3692
+ return [`${path}: expected ${String(schema.type)}`];
3693
+ }
3694
+ if (schema.enum && !schema.enum.some((value) => JSON.stringify(value) === JSON.stringify(input)))
3695
+ errors.push(`${path}: expected one of ${JSON.stringify(schema.enum)}`);
3696
+ const constant = "const" in schema ? schema.const : void 0;
3697
+ if (constant !== void 0 && constant !== input)
3698
+ errors.push(`${path}: expected ${JSON.stringify(constant)}`);
3699
+ if (schema.oneOf) {
3700
+ const branches = schema.oneOf.map((branch) => validateInput(branch, input, path));
3701
+ if (!branches.some((errors2) => errors2.length === 0)) {
3702
+ errors.push(
3703
+ ...branches.sort((a, b) => a.length - b.length)[0] ?? [
3704
+ `${path}: must match at least one schema branch`
3705
+ ]
3706
+ );
3707
+ }
3708
+ }
3709
+ if (typeof input === "object" && input !== null && !Array.isArray(input)) {
3710
+ const record = input;
3711
+ for (const name of [...schema.required ?? [], ...requiredForAction(schema, record.action)])
3712
+ if (!Object.hasOwn(record, name)) errors.push(`${path}.${name}: required`);
3713
+ for (const [name, property] of Object.entries(schema.properties ?? {}))
3714
+ if (Object.hasOwn(record, name))
3715
+ errors.push(...validateInput(property, record[name], `${path}.${name}`));
3716
+ }
3717
+ if (Array.isArray(input) && schema.items)
3718
+ input.forEach((value, index) => {
3719
+ errors.push(...validateInput(schema.items, value, `${path}[${index}]`));
3720
+ });
3721
+ return errors;
3722
+ }
3723
+
3724
+ // src/tools/action_requirements.ts
3725
+ var actionRequirements = {
3726
+ manage_hooks: { remove: ["id"] },
3727
+ shape_work: {
3728
+ create_quest: ["quest"],
3729
+ create_questline: ["questline"],
3730
+ attach_quest_to_questline: ["questId", "questlineId"],
3731
+ detach_quest_from_questline: ["questId"],
3732
+ dismiss_rumor: ["rumorId"],
3733
+ reopen_rumor: ["rumorId"],
3734
+ settle_rumor: ["rumorId"],
3735
+ update_rumor: ["rumorId"]
3736
+ },
3737
+ manage_repeatable: {
3738
+ create: ["repeatableQuest"],
3739
+ archive: ["repeatableQuestId"],
3740
+ update: ["repeatableQuestId"],
3741
+ spawn_due: ["now"]
3742
+ },
3743
+ organize_work: {
3744
+ add_unlock: ["fromQuestId", "toQuestId"],
3745
+ remove_unlock: ["fromQuestId", "toQuestId"],
3746
+ replace_unlocks: ["fromQuestIds", "toQuestId"],
3747
+ archive_questline: ["questlineId"],
3748
+ set_questline_fields: ["questlineId"]
3749
+ },
3750
+ plan_quest: { revise_objective: ["objective"] },
3751
+ run_quest: {
3752
+ abandon_and_spawn_followups: ["followups"],
3753
+ log_effort: ["effortSeconds"]
3754
+ },
3755
+ reward_work: { add: ["reward"], update: ["reward"], replace_repeatable_template: ["rewards"] }
3756
+ };
3757
+
3184
3758
  // src/tools/tool_metadata.ts
3185
3759
  function defineQuestlogTool(definition) {
3760
+ const requirements2 = actionRequirements[definition.name];
3761
+ if (requirements2) registerSchemaActions(definition.inputSchema, requirements2);
3762
+ const handler = definition.handler;
3763
+ definition.handler = (db, input) => {
3764
+ const errors = validateInput(definition.inputSchema, input);
3765
+ if (errors.length) {
3766
+ const action = input?.action;
3767
+ const example = minimalInput(definition.inputSchema, action);
3768
+ const message = errors.join("; ");
3769
+ return toolFailure("protocol", "invalid_input", message, message, {
3770
+ recovery: `${message}; pass ${JSON.stringify(example)}`
3771
+ });
3772
+ }
3773
+ return handler(db, input);
3774
+ };
3186
3775
  return definition;
3187
3776
  }
3188
3777
  function stringSchema(description, options = {}) {
@@ -3517,7 +4106,8 @@ function inspectQuestlogItemToolHandler(db, input) {
3517
4106
  detail: detailLevel === "full" ? detail : toQuestCompactDetail(detail)
3518
4107
  },
3519
4108
  related,
3520
- detailLevel
4109
+ detailLevel,
4110
+ deliveries: listDeliveries(db, { questId: detail.id })
3521
4111
  },
3522
4112
  { entities }
3523
4113
  );
@@ -3623,6 +4213,56 @@ var inspectQuestlogItemTool = defineQuestlogTool({
3623
4213
  handler: inspectQuestlogItemToolHandler
3624
4214
  });
3625
4215
 
4216
+ // src/tools/manage_hooks_tool.ts
4217
+ var manageHooksTool = defineQuestlogTool({
4218
+ name: "manage_hooks",
4219
+ description: "List or remove outbound webhook targets, run a tick, or inspect pending and failed deliveries.",
4220
+ whenToUse: "Use to operate configured outbound webhooks. Add and test targets with the CLI.",
4221
+ whenNotToUse: "Use quest tools to change work itself.",
4222
+ sideEffects: "writes_state",
4223
+ readOnly: false,
4224
+ supportsClarification: false,
4225
+ targetKinds: ["hook"],
4226
+ inputDescriptions: {
4227
+ action: "Operation to perform.",
4228
+ id: "Target id for removal.",
4229
+ dry_run: "Preview a tick without recording or sending."
4230
+ },
4231
+ outputDescription: "Returns redacted targets, delivery metadata, or a tick summary. Never returns signing secrets.",
4232
+ inputSchema: objectSchema(
4233
+ {
4234
+ action: enumSchema("Operation.", ["list", "remove", "tick", "deliveries"]),
4235
+ id: integerSchema("Target id."),
4236
+ dry_run: booleanSchema("Preview only.")
4237
+ },
4238
+ ["action"]
4239
+ ),
4240
+ handler: async (db, input) => {
4241
+ switch (input.action) {
4242
+ case "list":
4243
+ return toolSuccess("Webhook targets.", listHooks(db));
4244
+ case "deliveries":
4245
+ return toolSuccess("Pending and failed deliveries.", listDeliveries(db));
4246
+ case "remove":
4247
+ if (!Number.isSafeInteger(input.id) || Number(input.id) < 1)
4248
+ return toolFailure(
4249
+ "protocol",
4250
+ "invalid_input",
4251
+ "Target id required.",
4252
+ "Provide a positive target id.",
4253
+ { recovery: 'Pass {"action":"remove","id":1}' }
4254
+ );
4255
+ removeHook(db, Number(input.id));
4256
+ return toolSuccess("Webhook target removed.", {});
4257
+ case "tick":
4258
+ return toolSuccess(
4259
+ "Webhook tick completed.",
4260
+ await tickQuestlog(db, { dryRun: input.dry_run ?? false })
4261
+ );
4262
+ }
4263
+ }
4264
+ });
4265
+
3626
4266
  // src/tools/manage_repeatable_tool.ts
3627
4267
  function manageRepeatableToolHandler(db, input) {
3628
4268
  try {
@@ -4322,7 +4962,7 @@ function validateReviewInput(input) {
4322
4962
  'Use `filters.dayAt` only with `view: "quests.scheduled_for_day"`.'
4323
4963
  );
4324
4964
  }
4325
- if ((input.view === "rumors" || input.view === "questlines") && hasQuestFilters(input.filters)) {
4965
+ if ((input.view === "rumors" || input.view === "questlines" || input.view === "deliveries") && hasQuestFilters(input.filters)) {
4326
4966
  return toolFailure(
4327
4967
  "protocol",
4328
4968
  "invalid_input",
@@ -4390,6 +5030,15 @@ function reviewQuestlogToolHandler(db, input) {
4390
5030
  if (invalid) {
4391
5031
  return invalid;
4392
5032
  }
5033
+ if (input.view === "deliveries")
5034
+ return toolSuccess("Pending and failed deliveries.", {
5035
+ view: input.view,
5036
+ items: [],
5037
+ deliveries: listDeliveries(db),
5038
+ count: listDeliveries(db).length,
5039
+ appliedFilters: input.filters,
5040
+ evaluatedAt: input.now ?? Date.now()
5041
+ });
4393
5042
  const now = input.now ?? Date.now();
4394
5043
  let items = [];
4395
5044
  switch (input.view) {
@@ -4449,6 +5098,7 @@ var reviewQuestlogTool = defineQuestlogTool({
4449
5098
  inputSchema: objectSchema(
4450
5099
  {
4451
5100
  view: enumSchema("Which operational review surface to return.", [
5101
+ "deliveries",
4452
5102
  "questlines",
4453
5103
  "quests.active",
4454
5104
  "quests.available",
@@ -4725,7 +5375,7 @@ var rewardWorkTool = defineQuestlogTool({
4725
5375
  name: stringSchema("Reward name."),
4726
5376
  now: integerSchema("Optional update timestamp for the update action.")
4727
5377
  },
4728
- []
5378
+ ["kind", "name"]
4729
5379
  ),
4730
5380
  rewards: arraySchema(
4731
5381
  objectSchema(
@@ -5391,7 +6041,7 @@ var shapeWorkTool = defineQuestlogTool({
5391
6041
  title: stringSchema("Questline title."),
5392
6042
  description: stringSchema("Optional questline description.")
5393
6043
  },
5394
- []
6044
+ ["title"]
5395
6045
  ),
5396
6046
  rumorId: integerSchema("Rumor to settle, dismiss, or reopen."),
5397
6047
  dismissedAt: integerSchema("Optional dismissal timestamp."),
@@ -5650,6 +6300,7 @@ var tagWorkTool = defineQuestlogTool({
5650
6300
 
5651
6301
  // src/tools/tool_mapping.ts
5652
6302
  var questlogToolMappings = [
6303
+ { source: "tickQuestlog", tool: "manage_hooks", action: "tick" },
5653
6304
  { source: "searchQuestlog", tool: "search_questlog" },
5654
6305
  { source: "listRumors", tool: "review_questlog", action: "rumors" },
5655
6306
  { source: "listQuestlines", tool: "review_questlog", action: "questlines" },
@@ -5747,6 +6398,7 @@ var questlogToolMappings = [
5747
6398
 
5748
6399
  // src/tools/tool_registry.ts
5749
6400
  var questlogTools = [
6401
+ manageHooksTool,
5750
6402
  searchQuestlogTool,
5751
6403
  reviewQuestlogTool,
5752
6404
  inspectQuestlogItemTool,
@@ -5810,4 +6462,4 @@ __export(write_exports, {
5810
6462
  updateRumor: () => updateRumor
5811
6463
  });
5812
6464
 
5813
- export { initQuestlogTables, markers_exports as markers, read_exports as read, skills_exports as skills, soul_exports as soul, tools_exports as tools, write_exports as write };
6465
+ export { hooks_exports as hooks, initQuestlogTables, markers_exports as markers, read_exports as read, skills_exports as skills, soul_exports as soul, tools_exports as tools, write_exports as write };