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