kol.js 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +14 -9
  2. package/src/Client.test.ts +245 -64
  3. package/src/Client.ts +204 -193
  4. package/src/LoathingDate.test.ts +202 -0
  5. package/src/LoathingDate.ts +390 -0
  6. package/src/Player.test.ts +83 -82
  7. package/src/Player.ts +112 -181
  8. package/src/domains/AutomatedFuture.test.ts +19 -0
  9. package/src/domains/AutomatedFuture.ts +46 -0
  10. package/src/domains/Bookmobile.test.ts +20 -0
  11. package/src/domains/Bookmobile.ts +66 -0
  12. package/src/domains/ClanDungeon.ts +230 -0
  13. package/src/domains/Dreadsylvania.test.ts +424 -0
  14. package/src/domains/Dreadsylvania.ts +550 -0
  15. package/src/domains/Familiar.ts +82 -0
  16. package/src/domains/FloralMercantileExchange.test.ts +20 -0
  17. package/src/domains/FloralMercantileExchange.ts +51 -0
  18. package/src/{utils/leaderboard.test.ts → domains/Leaderboard.test.ts} +24 -4
  19. package/src/domains/Leaderboard.ts +173 -0
  20. package/src/domains/Players.test.ts +141 -0
  21. package/src/domains/Players.ts +108 -0
  22. package/src/domains/Raffle.test.ts +65 -0
  23. package/src/domains/Raffle.ts +60 -0
  24. package/src/domains/SkeletonOfCrimboPast.test.ts +55 -0
  25. package/src/domains/SkeletonOfCrimboPast.ts +38 -0
  26. package/src/domains/WardrobeOMatic.test.ts +141 -0
  27. package/src/domains/WardrobeOMatic.ts +650 -0
  28. package/src/domains/__fixtures__/automated_future.html +1 -0
  29. package/src/domains/__fixtures__/bookmobile_spooky.html +6 -0
  30. package/src/domains/__fixtures__/dread/cdr1-current.html +25 -0
  31. package/src/domains/__fixtures__/dread/cdr1-oldlogs-page0.html +25 -0
  32. package/src/domains/__fixtures__/dread/cdr2-current.html +25 -0
  33. package/src/domains/__fixtures__/dread/cdr2-oldlogs-page0.html +25 -0
  34. package/src/domains/__fixtures__/dread/raid-213013.html +24 -0
  35. package/src/domains/__fixtures__/dread/raid-217988.html +24 -0
  36. package/src/domains/__fixtures__/dread/raid-218029.html +24 -0
  37. package/src/domains/__fixtures__/dread/raid-218205.html +24 -0
  38. package/src/domains/__fixtures__/dread/raid-218286.html +24 -0
  39. package/src/domains/__fixtures__/dread/raid-218518.html +24 -0
  40. package/src/domains/__fixtures__/dread/raid-218519.html +24 -0
  41. package/src/domains/__fixtures__/flowers.html +229 -0
  42. package/src/domains/__fixtures__/raidlog.html +1 -0
  43. package/src/domains/__fixtures__/socp.html +1 -0
  44. package/src/index.ts +11 -5
  45. package/src/stats.ts +31 -0
  46. package/src/utils/kmail.test.ts +110 -0
  47. package/src/utils/kmail.ts +98 -3
  48. package/src/utils/utils.ts +45 -2
  49. package/src/Cache.ts +0 -33
  50. package/src/utils/leaderboard.ts +0 -78
  51. /package/src/{utils → domains}/__fixtures__/leaderboard_wotsf.html +0 -0
  52. /package/src/{__fixtures__ → domains/__fixtures__}/raffle.html +0 -0
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "kol.js",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "main": "src/index.ts",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./domains/*": "./src/domains/*.ts"
8
+ },
5
9
  "type": "module",
6
10
  "files": [
7
11
  "/src"
@@ -14,9 +18,9 @@
14
18
  "format": "prettier --write ."
15
19
  },
16
20
  "devDependencies": {
17
- "prettier": "^3.7.4",
21
+ "prettier": "^3.8.1",
18
22
  "typescript": "^5.9.3",
19
- "vitest": "^4.0.15"
23
+ "vitest": "^4.0.18"
20
24
  },
21
25
  "dependencies": {
22
26
  "async-mutex": "^0.5.0",
@@ -24,13 +28,14 @@
24
28
  "date-fns": "^4.1.0",
25
29
  "domhandler": "^5.0.3",
26
30
  "domutils": "^3.2.2",
27
- "got": "^14.6.5",
28
- "html-entities": "^2.6.0",
29
- "htmlparser2": "^10.0.0",
31
+ "emittery": "^1.1.0",
32
+ "entities": "^7.0.1",
33
+ "fetch-cookie": "^3.2.0",
34
+ "htmlparser2": "^10.1.0",
30
35
  "image-size": "^2.0.2",
31
- "querystring": "^0.2.1",
36
+ "kol-rng": "3",
37
+ "ofetch": "^1.5.1",
32
38
  "tough-cookie": "^6.0.0",
33
- "ts-dedent": "^2.2.0",
34
- "typed-emitter": "^2.1.0"
39
+ "ts-dedent": "^2.2.0"
35
40
  }
36
41
  }
@@ -1,25 +1,22 @@
1
1
  import { describe, expect, it, test, vi } from "vitest";
2
2
 
3
- import { Client } from "./Client.js";
3
+ import { AuthError, Client, RolloverError } from "./Client.js";
4
4
  import { loadFixture } from "./testUtils.js";
5
5
 
6
- const { json, text } = vi.hoisted(() => ({ json: vi.fn(), text: vi.fn() }));
7
-
8
- vi.mock("./Client.js", async (importOriginal) => {
9
- const client = await importOriginal<typeof import("./Client.js")>();
10
- client.Client.prototype.login = async () => true;
11
- client.Client.prototype.checkLoggedIn = async () => true;
12
- client.Client.prototype.fetchText = text;
13
- client.Client.prototype.fetchJson = json;
14
- return client;
15
- });
16
-
17
- const client = new Client("", "");
6
+ function mockSession(fn: () => unknown) {
7
+ return Object.assign(fn, {
8
+ raw: vi.fn(),
9
+ native: fetch,
10
+ create: vi.fn(),
11
+ }) as unknown as Client["session"];
12
+ }
18
13
 
19
14
  describe("LoathingChat", () => {
15
+ const client = new Client("", "");
16
+
20
17
  test("Can parse a regular message", async () => {
21
- json.mockResolvedValue({});
22
- json.mockResolvedValueOnce({
18
+ vi.spyOn(client, "fetchJson").mockResolvedValue({});
19
+ vi.spyOn(client, "fetchJson").mockResolvedValueOnce({
23
20
  msgs: [
24
21
  {
25
22
  msg: "testing",
@@ -51,7 +48,7 @@ describe("LoathingChat", () => {
51
48
  });
52
49
 
53
50
  test("Can parse a system message for rollover in 5 minutes", async () => {
54
- json.mockResolvedValueOnce({
51
+ vi.spyOn(client, "fetchJson").mockResolvedValueOnce({
55
52
  msgs: [
56
53
  {
57
54
  msg: "The system will go down for nightly maintenance in 5 minutes.",
@@ -82,7 +79,7 @@ describe("LoathingChat", () => {
82
79
  });
83
80
 
84
81
  test("Can parse a system message for rollover in one minute", async () => {
85
- json.mockResolvedValueOnce({
82
+ vi.spyOn(client, "fetchJson").mockResolvedValueOnce({
86
83
  msgs: [
87
84
  {
88
85
  msg: "The system will go down for nightly maintenance in 1 minute.",
@@ -113,7 +110,7 @@ describe("LoathingChat", () => {
113
110
  });
114
111
 
115
112
  test("Can parse a system message for rollover complete", async () => {
116
- json.mockResolvedValueOnce({
113
+ vi.spyOn(client, "fetchJson").mockResolvedValueOnce({
117
114
  msgs: [
118
115
  {
119
116
  msg: "Rollover is over.",
@@ -144,7 +141,7 @@ describe("LoathingChat", () => {
144
141
  });
145
142
 
146
143
  test("Can parse updates", async () => {
147
- json.mockResolvedValueOnce({
144
+ vi.spyOn(client, "fetchJson").mockResolvedValueOnce({
148
145
  output:
149
146
  '<hr><center><font color=green><b>Recent Announcements:</b></font></center><p><b>August 21</b> - That pocast you like is back in style. Get it on iTunes, or from the <A style="text-decoration: underline" target=_blank href=http://radio.kingdomofloathing.com/podcast.php>direct RSS feed</a> or <a style="text-decoration: underline" target=_blank href=http://shows.kingdomofloathing.com/The_KoL_Show_20250819.mp3>download the mp3 directly</a>.<p><b>August 15</b> - On September 1st commendations will be given out for softcore Under the Sea runs. The we\'ll be making some nerfs and the leaderboards will be reset for phase 2. <p><b>August 14</b> - Swim into the fall challenge path, 11,037 Leagues Under the Sea!<p><b>March 07</b> - Check out Jick\'s entry in the 2025 7-Day Roguelike Challenge. It\'s called Catacombo and you can <a href=https://zapjackson.itch.io/catacombo target=_blank style="text-decoration: underline">play it here on itch dot eye oh</a>.<p><b>March 06</b> - KoL\'s own Jick wrote a book, a choose-your-own-adventure about escaping from a wizard. You can <a style="text-decoration: underline" target=_blank href=https://www.amazon.com/Escape-Prison-Tower-Wizard-Adventures/dp/B0CTW34JV1/>buy it on Amazon dot com</a>. <p><hr>',
150
147
  msgs: [],
@@ -158,8 +155,10 @@ describe("LoathingChat", () => {
158
155
  });
159
156
 
160
157
  describe("Skill descriptions", () => {
158
+ const client = new Client("", "");
159
+
161
160
  test("can describe a Skill with no bluetext", async () => {
162
- text.mockResolvedValueOnce(
161
+ vi.spyOn(client, "fetchText").mockResolvedValueOnce(
163
162
  await loadFixture(
164
163
  __dirname,
165
164
  "desc_skill_overload_discarded_refridgerator.html",
@@ -174,7 +173,7 @@ describe("Skill descriptions", () => {
174
173
  });
175
174
 
176
175
  test("can describe a Skill with bluetext", async () => {
177
- text.mockResolvedValueOnce(
176
+ vi.spyOn(client, "fetchText").mockResolvedValueOnce(
178
177
  await loadFixture(__dirname, "desc_skill_impetuous_sauciness.html"),
179
178
  );
180
179
 
@@ -187,21 +186,21 @@ describe("Skill descriptions", () => {
187
186
  });
188
187
 
189
188
  describe("Familiars", () => {
189
+ const client = new Client("", "");
190
+
190
191
  test("can fetch all familiars", async () => {
191
- text.mockResolvedValueOnce(
192
+ vi.spyOn(client, "fetchText").mockResolvedValueOnce(
192
193
  await loadFixture(__dirname, "familiar_in_standard_run.html"),
193
194
  );
194
195
 
195
196
  const familiars = await client.getFamiliars();
196
197
 
197
- // Current
198
198
  expect(familiars).toContainEqual({
199
199
  id: 294,
200
200
  name: "Jill-of-All-Trades",
201
201
  image: "itemimages/darkjill2f.gif",
202
202
  });
203
203
 
204
- // Problematic
205
204
  expect(familiars).toContainEqual({
206
205
  id: 278,
207
206
  name: "Left-Hand Man",
@@ -213,50 +212,232 @@ describe("Familiars", () => {
213
212
  image: "otherimages/camelfam_left.gif",
214
213
  });
215
214
 
216
- // Just to be sure
217
215
  expect(familiars).toHaveLength(206);
218
216
  });
219
217
  });
220
218
 
221
- describe("Raffle", () => {
222
- it("can fetch the current raffle", async () => {
223
- text.mockResolvedValueOnce(await loadFixture(__dirname, "raffle.html"));
224
- text.mockResolvedValueOnce("<!-- itemid: 1 -->");
225
- text.mockResolvedValueOnce("<!-- itemid: 2 -->");
226
- text.mockResolvedValueOnce("<!-- itemid: 3 -->");
227
- text.mockResolvedValueOnce("<!-- itemid: 4 -->");
228
- text.mockResolvedValueOnce("<!-- itemid: 4 -->");
229
- text.mockResolvedValueOnce("<!-- itemid: 4 -->");
230
-
231
- const raffle = await client.getRaffle();
232
-
233
- expect(raffle).toMatchObject({
234
- today: {
235
- first: 1,
236
- second: 2,
237
- },
238
- yesterday: [
239
- {
240
- player: { id: 809337, name: "Collective Consciousness" },
241
- item: 3,
242
- tickets: 3333,
243
- },
244
- {
245
- player: { id: 852958, name: "Ryo_Sangnoir" },
246
- item: 4,
247
- tickets: 1011,
248
- },
249
- {
250
- player: { id: 1765063, name: "SSpectre_Karasu" },
251
- item: 4,
252
- tickets: 1000,
253
- },
254
- {
255
- player: { id: 1652370, name: "yueli7" },
256
- item: 4,
257
- tickets: 2040,
258
- },
259
- ],
219
+ describe("fetchJson error handling", () => {
220
+ it("throws on non-login errors", async () => {
221
+ const client = new Client("", "");
222
+ vi.spyOn(client, "login").mockResolvedValue(true);
223
+ client.session = mockSession(() => {
224
+ throw new Error("500 Internal Server Error");
225
+ });
226
+ await expect(client.fetchJson("api.php")).rejects.toThrow(
227
+ "500 Internal Server Error",
228
+ );
229
+ });
230
+
231
+ it("does not retry more than once on non-login errors", async () => {
232
+ const client = new Client("", "");
233
+ vi.spyOn(client, "login").mockResolvedValue(true);
234
+ let calls = 0;
235
+ client.session = mockSession(() => {
236
+ calls++;
237
+ throw new Error("persistent error");
238
+ });
239
+ await expect(client.fetchJson("test.php")).rejects.toThrow(
240
+ "persistent error",
241
+ );
242
+ expect(calls).toBe(1);
243
+ });
244
+
245
+ it("propagates RolloverError", async () => {
246
+ const client = new Client("", "");
247
+ vi.spyOn(client, "login").mockResolvedValue(true);
248
+ client.session = mockSession(() => {
249
+ throw new RolloverError();
250
+ });
251
+ await expect(client.fetchJson("test.php")).rejects.toBeInstanceOf(
252
+ RolloverError,
253
+ );
254
+ });
255
+
256
+ it("throws AuthError when login fails", async () => {
257
+ const client = new Client("", "");
258
+ vi.spyOn(client, "login").mockResolvedValue(false);
259
+ await expect(client.fetchJson("test.php")).rejects.toBeInstanceOf(
260
+ AuthError,
261
+ );
262
+ });
263
+ });
264
+
265
+ describe("fetchText error handling", () => {
266
+ it("throws on non-login errors instead of retrying", async () => {
267
+ const client = new Client("", "");
268
+ vi.spyOn(client, "login").mockResolvedValue(true);
269
+ client.session = mockSession(() => {
270
+ throw new Error("Network timeout");
271
+ });
272
+ await expect(client.fetchText("familiar.php")).rejects.toThrow(
273
+ "Network timeout",
274
+ );
275
+ });
276
+
277
+ it("does not retry more than once on non-login errors", async () => {
278
+ const client = new Client("", "");
279
+ vi.spyOn(client, "login").mockResolvedValue(true);
280
+ let calls = 0;
281
+ client.session = mockSession(() => {
282
+ calls++;
283
+ throw new Error("persistent error");
284
+ });
285
+ await expect(client.fetchText("test.php")).rejects.toThrow(
286
+ "persistent error",
287
+ );
288
+ expect(calls).toBe(1);
289
+ });
290
+ });
291
+
292
+ describe("rollover handling", () => {
293
+ it("detects rollover from login.php response", async () => {
294
+ const client = new Client("", "");
295
+ client.session = mockSession(
296
+ () => "The system is currently down for nightly maintenance.",
297
+ );
298
+ await client.login();
299
+ expect(client.isRollover()).toBe(true);
300
+
301
+ // All fetch methods should fail fast
302
+ await expect(client.fetchText("test.php")).rejects.toBeInstanceOf(
303
+ RolloverError,
304
+ );
305
+ await expect(client.fetchJson("test.php")).rejects.toBeInstanceOf(
306
+ RolloverError,
307
+ );
308
+ });
309
+
310
+ it("fetchText throws RolloverError immediately when in rollover", async () => {
311
+ const client = new Client("", "");
312
+ client.session = mockSession(
313
+ () => "The system is currently down for nightly maintenance",
314
+ );
315
+ await client.login();
316
+
317
+ let sessionCalled = false;
318
+ client.session = mockSession(() => {
319
+ sessionCalled = true;
320
+ return "";
260
321
  });
322
+ await expect(client.fetchText("test.php")).rejects.toBeInstanceOf(
323
+ RolloverError,
324
+ );
325
+ expect(sessionCalled).toBe(false);
326
+ });
327
+
328
+ it("fetchJson throws RolloverError immediately when in rollover", async () => {
329
+ const client = new Client("", "");
330
+ client.session = mockSession(
331
+ () => "The system is currently down for nightly maintenance",
332
+ );
333
+ await client.login();
334
+
335
+ let sessionCalled = false;
336
+ client.session = mockSession(() => {
337
+ sessionCalled = true;
338
+ return {};
339
+ });
340
+ await expect(client.fetchJson("test.php")).rejects.toBeInstanceOf(
341
+ RolloverError,
342
+ );
343
+ expect(sessionCalled).toBe(false);
344
+ });
345
+
346
+ it("recovers from rollover when server comes back", async () => {
347
+ vi.useFakeTimers();
348
+ const client = new Client("", "");
349
+
350
+ client.session = mockSession(
351
+ () => "The system is currently down for nightly maintenance",
352
+ );
353
+ await client.login();
354
+ expect(client.isRollover()).toBe(true);
355
+
356
+ client.session = mockSession(() => "<html>normal login page</html>");
357
+ await vi.advanceTimersByTimeAsync(60_000);
358
+
359
+ expect(client.isRollover()).toBe(false);
360
+ vi.useRealTimers();
361
+ });
362
+
363
+ it("emits rollover event after recovery when login succeeds", async () => {
364
+ vi.useFakeTimers();
365
+ const client = new Client("", "");
366
+
367
+ // Enter rollover
368
+ client.session = mockSession(
369
+ () => "The system is currently down for nightly maintenance",
370
+ );
371
+ await client.login();
372
+
373
+ // Recover: checkForRollover sees normal page, then login flow works
374
+ let callCount = 0;
375
+ client.session = mockSession(() => {
376
+ callCount++;
377
+ // Call 1: #checkForRollover fetches login.php (normal)
378
+ if (callCount === 1) return "<html>normal login page</html>";
379
+ // Call 2+: checkLoggedIn/login succeed
380
+ return { pwd: "abc123" };
381
+ });
382
+
383
+ await vi.advanceTimersByTimeAsync(60_000);
384
+ expect(client.isRollover()).toBe(false);
385
+
386
+ // login needs checkLoggedIn to FAIL first so it goes through the
387
+ // full login flow where the latch is checked. Make first checkLoggedIn
388
+ // fail (no session yet), then login POST + second checkLoggedIn succeed.
389
+ callCount = 0;
390
+ client.session = mockSession(() => {
391
+ callCount++;
392
+ // Call 1: checkLoggedIn → returns non-object (fails validation)
393
+ if (callCount === 1) return "<html>not logged in</html>";
394
+ // Call 2: login POST → success (no rollover pattern)
395
+ if (callCount === 2) return "<html>game frameset</html>";
396
+ // Call 3: second checkLoggedIn → success
397
+ return { pwd: "def456" };
398
+ });
399
+
400
+ const rolloverSpy = vi.fn();
401
+ client.on("rollover", rolloverSpy);
402
+ const loggedIn = await client.login();
403
+ expect(loggedIn).toBe(true);
404
+ expect(rolloverSpy).toHaveBeenCalledOnce();
405
+
406
+ vi.useRealTimers();
407
+ });
408
+
409
+ it("stays in rollover if server is unreachable", async () => {
410
+ vi.useFakeTimers();
411
+ const client = new Client("", "");
412
+
413
+ client.session = mockSession(
414
+ () => "The system is currently down for nightly maintenance",
415
+ );
416
+ await client.login();
417
+ expect(client.isRollover()).toBe(true);
418
+
419
+ client.session = mockSession(() => {
420
+ throw new Error("Network error");
421
+ });
422
+ await vi.advanceTimersByTimeAsync(60_000);
423
+ expect(client.isRollover()).toBe(true);
424
+
425
+ client.session = mockSession(() => "<html>normal login page</html>");
426
+ await vi.advanceTimersByTimeAsync(60_000);
427
+ expect(client.isRollover()).toBe(false);
428
+
429
+ vi.useRealTimers();
430
+ });
431
+
432
+ it("does not recurse through fetchText/login during rollover check", async () => {
433
+ const client = new Client("", "");
434
+ let sessionCallCount = 0;
435
+ client.session = mockSession(() => {
436
+ sessionCallCount++;
437
+ return "The system is currently down for nightly maintenance";
438
+ });
439
+
440
+ await client.login();
441
+ expect(sessionCallCount).toBeLessThanOrEqual(3);
261
442
  });
262
443
  });