kol.js 0.3.0 → 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 (51) hide show
  1. package/package.json +10 -5
  2. package/src/Client.test.ts +245 -64
  3. package/src/Client.ts +201 -191
  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 +10 -4
  45. package/src/stats.ts +31 -0
  46. package/src/utils/kmail.ts +3 -3
  47. package/src/utils/utils.ts +43 -0
  48. package/src/Cache.ts +0 -33
  49. package/src/utils/leaderboard.ts +0 -78
  50. /package/src/{utils → domains}/__fixtures__/leaderboard_wotsf.html +0 -0
  51. /package/src/{__fixtures__ → domains/__fixtures__}/raffle.html +0 -0
@@ -1,11 +1,14 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { loadFixture } from "../testUtils.js";
3
- import { parseLeaderboard } from "./leaderboard.js";
3
+ import { Leaderboard } from "./Leaderboard.js";
4
4
 
5
5
  describe("Leaderboards", () => {
6
6
  it("can parse a regular path leaderboard", async () => {
7
- const page = await loadFixture(__dirname, "leaderboard_wotsf.html");
8
- const leaderboard = parseLeaderboard(page);
7
+ const page = await loadFixture(
8
+ import.meta.dirname,
9
+ "leaderboard_wotsf.html",
10
+ );
11
+ const leaderboard = Leaderboard.parse(page);
9
12
 
10
13
  // Group name
11
14
  expect(leaderboard.name).toBe(
@@ -45,3 +48,20 @@ describe("Leaderboards", () => {
45
48
  );
46
49
  });
47
50
  });
51
+
52
+ describe("boardIdForStandardYear", () => {
53
+ afterEach(() => {
54
+ vi.useRealTimers();
55
+ });
56
+
57
+ it("returns 999 for the current year", () => {
58
+ vi.useFakeTimers({ now: new Date("2026-06-15") });
59
+ expect(Leaderboard.boardIdForStandardYear(2026)).toBe(999);
60
+ });
61
+
62
+ it("returns the correct id for a past year", () => {
63
+ expect(Leaderboard.boardIdForStandardYear(2015)).toBe(998);
64
+ expect(Leaderboard.boardIdForStandardYear(2016)).toBe(997);
65
+ expect(Leaderboard.boardIdForStandardYear(2020)).toBe(993);
66
+ });
67
+ });
@@ -0,0 +1,173 @@
1
+ import { selectAll, selectOne } from "css-select";
2
+ import { parseDocument } from "htmlparser2";
3
+ import { Element, isComment, Text } from "domhandler";
4
+ import { innerText } from "domutils";
5
+
6
+ import type { Client } from "../Client.js";
7
+
8
+ export type LeaderboardInfo = {
9
+ name: string;
10
+ boards: SubboardInfo[];
11
+ };
12
+
13
+ export type SubboardInfo = {
14
+ name: string;
15
+ runs: RunInfo[];
16
+ updated: Date | null;
17
+ };
18
+
19
+ export type RunInfo = {
20
+ playerName: string;
21
+ playerId: number;
22
+ days: string;
23
+ turns: string;
24
+ };
25
+
26
+ const BOARD_MAPPINGS = {
27
+ "Clan Dungeons": 8,
28
+ "Bees Hate You": 9,
29
+ "Way of the Surprising Fist": 10,
30
+ Trendy: 11,
31
+ "Avatar of Boris": 12,
32
+ "Bugbear Invasion": 13,
33
+ "Zombie Slayer": 14,
34
+ "Class Act": 15,
35
+ "Avatar of Jarlsberg": 16,
36
+ "BIG!": 17,
37
+ KOLHS: 18,
38
+ "Class Act II": 19,
39
+ "Avatar of Sneaky Pete": 20,
40
+ "Slow and Steady": 21,
41
+ "Heavy Rains": 22,
42
+ Picky: 23,
43
+ "Actually Ed the Undying": 24,
44
+ "One Crazy Random Summer": 25,
45
+ "Community Service": 26,
46
+ Batfellow: 27,
47
+ "Avatar of West of Loathing": 28,
48
+ "The Source": 29,
49
+ "Nuclear Autumn": 30,
50
+ "Gelatinous Noob": 31,
51
+ "License to Adventure": 32,
52
+ "Live. Ascend. Repeat.": 33,
53
+ "Pocket Familiars": 34,
54
+ "G Lover": 35,
55
+ "Disguises Delimit": 36,
56
+ "Dark Gyffte": 37,
57
+ "Two Crazy Random Summer": 38,
58
+ "Kingdom of Exploathing": 39,
59
+ "Path of the Plumber": 40,
60
+ "Low Key Summer": 41,
61
+ "Grey Goo": 42,
62
+ "You, Robot": 43,
63
+ "Quantum Terrarium": 44,
64
+ "Wild Fire": 45,
65
+ "Grey You": 46,
66
+ Journeyman: 47,
67
+ "Fall of the Dinosaurs": 48,
68
+ "Avatars of Shadows Over Loathing": 49,
69
+ "Legacy of Loathing": 50,
70
+ "A Shrunken Adventurer am I": 51,
71
+ WereProfessor: 52,
72
+ "11 Things I Hate About U": 53,
73
+ "Avant Guard": 54,
74
+ "Z is for Zootomist": 55,
75
+ "Hat Trick": 56,
76
+ "11,037 Leagues Under the Sea": 57,
77
+ Standard: 999,
78
+ "Elf Gratitude": 900,
79
+ } as const;
80
+
81
+ export { BOARD_MAPPINGS };
82
+
83
+ export type BoardName = keyof typeof BOARD_MAPPINGS;
84
+
85
+ const blankNode = new Text("");
86
+
87
+ export class Leaderboard {
88
+ #client: Client;
89
+
90
+ constructor(client: Client) {
91
+ this.#client = client;
92
+ }
93
+
94
+ static get boardNames(): BoardName[] {
95
+ return Object.keys(BOARD_MAPPINGS) as BoardName[];
96
+ }
97
+
98
+ static boardIdFor(name: BoardName): number {
99
+ return BOARD_MAPPINGS[name];
100
+ }
101
+
102
+ static boardIdForStandardYear(year: number): number {
103
+ if (year === new Date().getFullYear()) return 999;
104
+ return 998 + 2015 - year;
105
+ }
106
+
107
+ static parse(page: string): LeaderboardInfo {
108
+ const doc = parseDocument(page);
109
+ const [container, ...boards] = selectAll("table", doc);
110
+
111
+ return {
112
+ name: innerText(selectOne("tr", container) ?? blankNode)
113
+ .replace(/\s+/g, " ")
114
+ .trim(),
115
+ boards: boards
116
+ .slice(1)
117
+ .filter((board) => {
118
+ if (selectAll(":scope > tr, :scope > tbody > tr", board).length <= 1)
119
+ return false;
120
+ const text = innerText(selectOne("tr", board) ?? blankNode);
121
+ return text.match(/^((Fast|Funn|B)est|Most (Goo|Elf))/);
122
+ })
123
+ .map((subboard) => {
124
+ const rows = selectAll("tr", subboard);
125
+
126
+ return {
127
+ name: innerText(rows[0]),
128
+ runs: selectAll("td tr", rows[1])
129
+ .slice(2)
130
+ .map((node) => {
131
+ const rowText = selectAll("td", node).map((col) =>
132
+ innerText(col).replace(/&amp;nbsp;/g, ""),
133
+ );
134
+ const playerLink = (selectOne("a", node) as Element | null)
135
+ ?.attribs.href;
136
+ const hasTwoNumbers = !!parseInt(rowText[rowText.length - 2]);
137
+ return {
138
+ playerName: rowText
139
+ .slice(0, rowText.length - (hasTwoNumbers ? 2 : 1))
140
+ .join("")
141
+ .trim()
142
+ .toString(),
143
+ playerId: Number(
144
+ playerLink?.substring(playerLink.indexOf("who=") + 4) ??
145
+ "0",
146
+ ),
147
+ days: hasTwoNumbers
148
+ ? rowText[rowText.length - 2].toString() || "0"
149
+ : "",
150
+ turns: rowText[rowText.length - 1].toString() || "0",
151
+ };
152
+ }),
153
+ updated:
154
+ subboard.nextSibling && isComment(subboard.nextSibling)
155
+ ? new Date(subboard.nextSibling.data.slice(9, -1))
156
+ : null,
157
+ };
158
+ }),
159
+ };
160
+ }
161
+
162
+ async getLeaderboard(boardId: number): Promise<LeaderboardInfo> {
163
+ const page = await this.#client.fetchText("museum.php", {
164
+ query: {
165
+ floor: 1,
166
+ place: "leaderboards",
167
+ whichboard: boardId,
168
+ },
169
+ });
170
+
171
+ return Leaderboard.parse(page);
172
+ }
173
+ }
@@ -0,0 +1,141 @@
1
+ import * as path from "node:path";
2
+ import { beforeEach, describe, expect, test, vi } from "vitest";
3
+
4
+ import { loadFixture } from "../testUtils.js";
5
+ import { Client } from "../Client.js";
6
+ import { Player } from "../Player.js";
7
+
8
+ const fixturesDir = path.resolve(import.meta.dirname, "..");
9
+
10
+ const { json, text } = vi.hoisted(() => ({ json: vi.fn(), text: vi.fn() }));
11
+
12
+ vi.mock("../Client.js", async (importOriginal) => {
13
+ const client = await importOriginal<typeof import("../Client.js")>();
14
+ client.Client.prototype.login = async () => true;
15
+ client.Client.prototype.checkLoggedIn = async () => true;
16
+ client.Client.prototype.fetchText = text;
17
+ client.Client.prototype.fetchJson = json;
18
+ return client;
19
+ });
20
+
21
+ let client: Client;
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ client = new Client("", "");
26
+ });
27
+
28
+ describe("Players.resolve", () => {
29
+ test("resolves a player by name via search", async () => {
30
+ text.mockResolvedValueOnce(
31
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
32
+ );
33
+
34
+ const player = await client.players.resolve("mad carew");
35
+
36
+ expect(player).not.toBeNull();
37
+ expect(player!.id).toBe(263717);
38
+ expect(player!.name).toBe("Mad Carew");
39
+ expect(player).toBeInstanceOf(Player);
40
+ expect(player).not.toBeInstanceOf(Player.Profiled);
41
+ });
42
+
43
+ test("returns null for unknown player", async () => {
44
+ text.mockResolvedValueOnce("<html>No results</html>");
45
+
46
+ const player = await client.players.resolve("nonexistent");
47
+
48
+ expect(player).toBeNull();
49
+ });
50
+
51
+ test("returns cached player on second call", async () => {
52
+ text.mockResolvedValueOnce(
53
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
54
+ );
55
+
56
+ const first = await client.players.resolve("mad carew");
57
+ const second = await client.players.resolve("Mad Carew");
58
+
59
+ expect(first).toBe(second);
60
+ expect(text).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ test("can resolve by id after resolving by name", async () => {
64
+ text.mockResolvedValueOnce(
65
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
66
+ );
67
+
68
+ const byName = await client.players.resolve("mad carew");
69
+ const byId = await client.players.resolve(263717);
70
+
71
+ expect(byName).toBe(byId);
72
+ expect(text).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
75
+
76
+ describe("Players.fetch", () => {
77
+ test("fetches a full profile", async () => {
78
+ // First call: search
79
+ text.mockResolvedValueOnce(
80
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
81
+ );
82
+ // Second call: whois (for id resolution)
83
+ // Third call: profile
84
+ text.mockResolvedValueOnce(
85
+ await loadFixture(fixturesDir, "showplayer_regular.html"),
86
+ );
87
+
88
+ const profiled = await client.players.fetch("mad carew");
89
+
90
+ expect(profiled).not.toBeNull();
91
+ expect(profiled).toBeInstanceOf(Player.Profiled);
92
+ expect(profiled!.id).toBe(263717);
93
+ expect(profiled!.avatar).toContain("<svg");
94
+ expect(profiled!.ascensions).toBeGreaterThan(0);
95
+ });
96
+
97
+ test("returns cached Profiled on second call", async () => {
98
+ text.mockResolvedValueOnce(
99
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
100
+ );
101
+ text.mockResolvedValueOnce(
102
+ await loadFixture(fixturesDir, "showplayer_regular.html"),
103
+ );
104
+
105
+ const first = await client.players.fetch("mad carew");
106
+ const second = await client.players.fetch(263717);
107
+
108
+ expect(first).toBe(second);
109
+ });
110
+
111
+ test("returns null when profile parsing fails", async () => {
112
+ text.mockResolvedValueOnce(
113
+ await loadFixture(fixturesDir, "searchplayer_mad_carew.html"),
114
+ );
115
+ text.mockResolvedValueOnce("<html>Not a profile</html>");
116
+
117
+ const profiled = await client.players.fetch("mad carew");
118
+
119
+ expect(profiled).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe("Players.getNameFromId", () => {
124
+ test("parses name from whois response", async () => {
125
+ text.mockResolvedValueOnce(
126
+ '<a target=mainpane href="showplayer.php?who=263717"><b>Mad Carew (#263717)</b></a>',
127
+ );
128
+
129
+ const name = await client.players.getNameFromId(263717);
130
+
131
+ expect(name).toBe("Mad Carew");
132
+ });
133
+
134
+ test("returns null on error", async () => {
135
+ text.mockRejectedValueOnce(new Error("network error"));
136
+
137
+ const name = await client.players.getNameFromId(999);
138
+
139
+ expect(name).toBeNull();
140
+ });
141
+ });
@@ -0,0 +1,108 @@
1
+ import type { Client } from "../Client.js";
2
+ import { Player } from "../Player.js";
3
+
4
+ type SearchData = { level: number; kolClass: string };
5
+
6
+ export class Players {
7
+ #client: Client;
8
+ #byId = new Map<number, Player>();
9
+ #nameToId = new Map<string, number>();
10
+ #searchData = new Map<number, SearchData>();
11
+
12
+ constructor(client: Client) {
13
+ this.#client = client;
14
+ }
15
+
16
+ #getCached(identifier: string | number): Player | undefined {
17
+ const id = Number(identifier);
18
+ if (!Number.isNaN(id) || typeof identifier === "number") {
19
+ return this.#byId.get(id);
20
+ }
21
+ const resolvedId = this.#nameToId.get(String(identifier).toLowerCase());
22
+ return resolvedId !== undefined ? this.#byId.get(resolvedId) : undefined;
23
+ }
24
+
25
+ #cache(player: Player, searchData?: SearchData): void {
26
+ this.#byId.set(player.id, player);
27
+ this.#nameToId.set(player.name.toLowerCase(), player.id);
28
+ if (searchData) this.#searchData.set(player.id, searchData);
29
+ }
30
+
31
+ async resolve(identifier: string | number): Promise<Player | null> {
32
+ return this.#getCached(identifier) ?? this.#resolveUncached(identifier);
33
+ }
34
+
35
+ async fetch(identifier: string | number): Promise<Player.Profiled | null> {
36
+ const cached = this.#getCached(identifier);
37
+ if (cached instanceof Player.Profiled) return cached;
38
+
39
+ const identity = cached ?? (await this.#resolveUncached(identifier));
40
+ if (!identity) return null;
41
+
42
+ const html = await this.#client.fetchText("showplayer.php", {
43
+ query: { who: identity.id },
44
+ });
45
+
46
+ const profileData = await Player.Profiled.parseProfile(html);
47
+ if (!profileData) return null;
48
+
49
+ const search = this.#searchData.get(identity.id);
50
+
51
+ const profiled = new Player.Profiled(this.#client, {
52
+ id: identity.id,
53
+ name: identity.name,
54
+ level: search?.level ?? 0,
55
+ kolClass: search?.kolClass ?? "Unknown",
56
+ ...profileData,
57
+ });
58
+
59
+ this.#cache(profiled);
60
+ return profiled;
61
+ }
62
+
63
+ async getNameFromId(id: number): Promise<string | null> {
64
+ try {
65
+ const html = await this.#client.fetchText("submitnewchat.php", {
66
+ query: { graf: `/whois ${id}` },
67
+ });
68
+ return Player.parseNameFromWhois(html);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ async #resolveUncached(identifier: string | number): Promise<Player | null> {
75
+ const id = Number(identifier);
76
+
77
+ if (!Number.isNaN(id) || typeof identifier === "number") {
78
+ const name = await this.getNameFromId(id);
79
+ if (!name) return null;
80
+ return this.#resolveByName(name);
81
+ }
82
+
83
+ return this.#resolveByName(identifier as string);
84
+ }
85
+
86
+ async #resolveByName(name: string): Promise<Player | null> {
87
+ try {
88
+ const html = await this.#client.fetchText("searchplayer.php", {
89
+ query: {
90
+ searchstring: name.replace(/_/g, "\\_"),
91
+ searching: "Yep.",
92
+ for: "",
93
+ startswith: 1,
94
+ hardcoreonly: 0,
95
+ },
96
+ });
97
+
98
+ const result = Player.parseSearch(html);
99
+ if (!result) return null;
100
+
101
+ const player = new Player(this.#client, result.id, result.name);
102
+ this.#cache(player, { level: result.level, kolClass: result.kolClass });
103
+ return player;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { Client } from "../Client.js";
4
+ import { loadFixture } from "../testUtils.js";
5
+ import { Raffle } from "./Raffle.js";
6
+
7
+ const { json, text } = vi.hoisted(() => ({ json: vi.fn(), text: vi.fn() }));
8
+
9
+ vi.mock("../Client.js", async (importOriginal) => {
10
+ const client = await importOriginal<typeof import("../Client.js")>();
11
+ client.Client.prototype.login = async () => true;
12
+ client.Client.prototype.checkLoggedIn = async () => true;
13
+ client.Client.prototype.fetchText = text;
14
+ client.Client.prototype.fetchJson = json;
15
+ return client;
16
+ });
17
+
18
+ const client = new Client("", "");
19
+
20
+ describe("Raffle", () => {
21
+ it("can fetch the current raffle", async () => {
22
+ text.mockResolvedValueOnce(
23
+ await loadFixture(import.meta.dirname, "raffle.html"),
24
+ );
25
+ text.mockResolvedValueOnce("<!-- itemid: 1 -->");
26
+ text.mockResolvedValueOnce("<!-- itemid: 2 -->");
27
+ text.mockResolvedValueOnce("<!-- itemid: 3 -->");
28
+ text.mockResolvedValueOnce("<!-- itemid: 4 -->");
29
+ text.mockResolvedValueOnce("<!-- itemid: 4 -->");
30
+ text.mockResolvedValueOnce("<!-- itemid: 4 -->");
31
+ json.mockResolvedValueOnce({ daynumber: "100" });
32
+
33
+ const raffle = new Raffle(client);
34
+ const result = await raffle.getRaffle();
35
+
36
+ expect(result).toMatchObject({
37
+ today: {
38
+ first: 1,
39
+ second: 2,
40
+ },
41
+ yesterday: [
42
+ {
43
+ player: { id: 809337, name: "Collective Consciousness" },
44
+ item: 3,
45
+ tickets: 3333,
46
+ },
47
+ {
48
+ player: { id: 852958, name: "Ryo_Sangnoir" },
49
+ item: 4,
50
+ tickets: 1011,
51
+ },
52
+ {
53
+ player: { id: 1765063, name: "SSpectre_Karasu" },
54
+ item: 4,
55
+ tickets: 1000,
56
+ },
57
+ {
58
+ player: { id: 1652370, name: "yueli7" },
59
+ item: 4,
60
+ tickets: 2040,
61
+ },
62
+ ],
63
+ });
64
+ });
65
+ });
@@ -0,0 +1,60 @@
1
+ import type { Client } from "../Client.js";
2
+ import { Player } from "../Player.js";
3
+
4
+ export type RaffleWinner = {
5
+ player: Player;
6
+ item: number;
7
+ tickets: number;
8
+ place: number;
9
+ };
10
+
11
+ export type RaffleResult = {
12
+ today: { first: number | null; second: number | null };
13
+ yesterday: RaffleWinner[];
14
+ gameday: number;
15
+ };
16
+
17
+ export class Raffle {
18
+ #client: Client;
19
+
20
+ constructor(client: Client) {
21
+ this.#client = client;
22
+ }
23
+
24
+ async getRaffle(): Promise<RaffleResult> {
25
+ const page = await this.#client.fetchText("raffle.php");
26
+
27
+ const todayMatches = page.matchAll(
28
+ /<tr><td align=right>(?:First|Second) Prize:<\/td>.*?descitem\((\d+)\)/g,
29
+ );
30
+ const [first, second] = await Promise.all(
31
+ todayMatches
32
+ ? [...todayMatches].map(
33
+ async (p) => await this.#client.descIdToId(Number(p[1])),
34
+ )
35
+ : [null, null],
36
+ );
37
+
38
+ const winnerMatches = page.matchAll(
39
+ /<tr><td class=small><a href='showplayer\.php\?who=\d+'>(.*?) \(#(\d+)\).*?descitem\((\d+)\).*?([\d,]+)<\/td><\/tr>/g,
40
+ );
41
+ const yesterday = await Promise.all(
42
+ winnerMatches
43
+ ? [...winnerMatches].map(async (w, i) => ({
44
+ player: new Player(this.#client, Number(w[2]), w[1]),
45
+ item: await this.#client.descIdToId(Number(w[3])),
46
+ tickets: Number(w[4].replace(",", "")),
47
+ place: Math.min(i + 1, 2),
48
+ }))
49
+ : [],
50
+ );
51
+
52
+ const { daynumber } = await this.#client.fetchStatus();
53
+
54
+ return {
55
+ today: { first: first ?? null, second: second ?? null },
56
+ yesterday,
57
+ gameday: Number(daynumber),
58
+ };
59
+ }
60
+ }
@@ -0,0 +1,55 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { Client } from "../Client.js";
4
+ import { loadFixture } from "../testUtils.js";
5
+ import { SkeletonOfCrimboPast } from "./SkeletonOfCrimboPast.js";
6
+
7
+ const { text } = vi.hoisted(() => ({ text: vi.fn() }));
8
+
9
+ vi.mock("../Client.js", async (importOriginal) => {
10
+ const client = await importOriginal<typeof import("../Client.js")>();
11
+ client.Client.prototype.login = async () => true;
12
+ client.Client.prototype.checkLoggedIn = async () => true;
13
+ client.Client.prototype.fetchText = text;
14
+ return client;
15
+ });
16
+
17
+ const client = new Client("", "");
18
+
19
+ const terrariumWithSocp = `onClick='fam(326)'><img src="https://d2uyhvukfffg5a.cloudfront.net/itemimages/socp.gif" 1-pound Skeleton of Crimbo Past (`;
20
+
21
+ describe("SkeletonOfCrimboPast", () => {
22
+ beforeEach(() => text.mockReset());
23
+
24
+ it("returns null if player has no familiar", async () => {
25
+ text.mockResolvedValueOnce(""); // empty terrarium
26
+ const socp = new SkeletonOfCrimboPast(client);
27
+ expect(await socp.getDailySpecial()).toBeNull();
28
+ });
29
+
30
+ it("caches the familiar check", async () => {
31
+ text.mockResolvedValueOnce(""); // empty terrarium
32
+ const socp = new SkeletonOfCrimboPast(client);
33
+ await socp.getDailySpecial();
34
+ await socp.getDailySpecial();
35
+ // getFamiliars only called once
36
+ expect(text).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it("returns null if page does not match", async () => {
40
+ text.mockResolvedValueOnce(terrariumWithSocp);
41
+ text.mockResolvedValueOnce("<html>no match here</html>");
42
+ const socp = new SkeletonOfCrimboPast(client);
43
+ expect(await socp.getDailySpecial()).toBeNull();
44
+ });
45
+
46
+ it("parses the daily special", async () => {
47
+ text.mockResolvedValueOnce(terrariumWithSocp);
48
+ text.mockResolvedValueOnce(
49
+ await loadFixture(import.meta.dirname, "socp.html"),
50
+ );
51
+ const socp = new SkeletonOfCrimboPast(client);
52
+ const special = await socp.getDailySpecial();
53
+ expect(special).toEqual({ descId: 153919945, price: 1388 });
54
+ });
55
+ });
@@ -0,0 +1,38 @@
1
+ import type { Client } from "../Client.js";
2
+
3
+ export interface DailySpecial {
4
+ descId: number;
5
+ price: number;
6
+ }
7
+
8
+ export class SkeletonOfCrimboPast {
9
+ #client: Client;
10
+ #hasFamiliar: boolean | null = null;
11
+
12
+ constructor(client: Client) {
13
+ this.#client = client;
14
+ }
15
+
16
+ async hasFamiliar(): Promise<boolean> {
17
+ if (this.#hasFamiliar === null) {
18
+ const fams = await this.#client.getFamiliars();
19
+ this.#hasFamiliar = fams.some((f) => f.id === 326);
20
+ }
21
+ return this.#hasFamiliar;
22
+ }
23
+
24
+ async getDailySpecial(): Promise<DailySpecial | null> {
25
+ if (!(await this.hasFamiliar())) return null;
26
+
27
+ const page = await this.#client.fetchText("main.php", {
28
+ query: { talktosocp: "1" },
29
+ });
30
+
31
+ const match = page.match(
32
+ /Daily Special:.*?descitem\((\d+)\).*?\((\d+) knucklebones\)/,
33
+ );
34
+
35
+ if (!match) return null;
36
+ return { descId: Number(match[1]), price: Number(match[2]) };
37
+ }
38
+ }