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
@@ -17,104 +17,105 @@ vi.mock("./Client.js", async (importOriginal) => {
17
17
 
18
18
  const client = new Client("", "");
19
19
 
20
- describe("Player searching", () => {
21
- test("Can search for a player by name", async () => {
22
- vi.mocked(text).mockResolvedValueOnce(
23
- await loadFixture(__dirname, "searchplayer_mad_carew.html"),
24
- );
20
+ describe("Player.parseSearch", () => {
21
+ test("Can parse a player search result", async () => {
22
+ const html = await loadFixture(__dirname, "searchplayer_mad_carew.html");
23
+ const result = Player.parseSearch(html);
24
+
25
+ expectNotNull(result);
26
+ expect(result.id).toBe(263717);
27
+ expect(result.name).toBe("Mad Carew");
28
+ });
29
+
30
+ test("Can parse a player in Valhalla", async () => {
31
+ const html = await loadFixture(__dirname, "searchplayer_beldur.html");
32
+ const result = Player.parseSearch(html);
25
33
 
26
- const player = await Player.fromName(client, "mad carew");
34
+ expectNotNull(result);
35
+ expect(result.id).toBe(1046951);
36
+ expect(result.name).toBe("Beldur");
37
+ expect(result.level).toBe(0);
38
+ expect(result.kolClass).toBe("Astral Spirit");
39
+ });
40
+ });
27
41
 
28
- expectNotNull(player);
42
+ describe("Player.Profiled.parseProfile", () => {
43
+ test("Can parse a profile picture", async () => {
44
+ const html = await loadFixture(__dirname, "showplayer_regular.html");
45
+ const result = await Player.Profiled.parseProfile(html);
29
46
 
30
- expect(player.id).toBe(263717);
31
- // Learns correct capitalisation
32
- expect(player.name).toBe("Mad Carew");
47
+ expectNotNull(result);
48
+ expect(result.avatar).toContain("<svg");
33
49
  });
34
50
 
35
- test("Can search for a player in Valhalla by name ", async () => {
36
- vi.mocked(text).mockResolvedValueOnce(
37
- await loadFixture(__dirname, "searchplayer_beldur.html"),
38
- );
51
+ test("Can parse a profile picture on dependence day", async () => {
52
+ const html = await loadFixture(__dirname, "showplayer_dependence_day.html");
53
+ const result = await Player.Profiled.parseProfile(html);
39
54
 
40
- const player = await Player.fromName(client, "Beldur");
55
+ expectNotNull(result);
56
+ expect(result.avatar).toContain("<svg");
57
+ });
41
58
 
42
- expectNotNull(player);
59
+ test("Can parse an avatar when the player has been painted gold", async () => {
60
+ const html = await loadFixture(__dirname, "showplayer_golden_gun.html");
61
+ const result = await Player.Profiled.parseProfile(html);
43
62
 
44
- expect(player.id).toBe(1046951);
45
- // Learns correct capitalisation
46
- expect(player.name).toBe("Beldur");
47
- expect(player.level).toBe(0);
48
- // We display Astral Spirit even though the search page says Seal Clubber.
49
- expect(player.kolClass).toBe("Astral Spirit");
63
+ expectNotNull(result);
64
+ expect(result.avatar).toContain("<svg");
50
65
  });
51
66
  });
52
67
 
53
- describe("Profile parsing", () => {
54
- test("Can parse a profile picture", async () => {
55
- vi.mocked(text).mockResolvedValueOnce(
56
- await loadFixture(__dirname, "showplayer_regular.html"),
57
- );
58
-
59
- const player = await new Player(
60
- client,
61
- 2264486,
62
- "SSBBHax",
63
- 1,
64
- "Sauceror",
65
- ).full();
66
-
67
- expectNotNull(player);
68
- expect(player.avatar).toContain("<svg");
68
+ describe("Player class", () => {
69
+ test("toString returns formatted string", () => {
70
+ const player = new Player(client, 1197090, "gausie");
71
+ expect(player.toString()).toBe("gausie (#1197090)");
69
72
  });
70
73
 
71
- test("Can parse a profile picture on dependence day", async () => {
72
- vi.mocked(text).mockResolvedValueOnce(
73
- await loadFixture(__dirname, "showplayer_dependence_day.html"),
74
- );
75
-
76
- const player = await new Player(
77
- client,
78
- 3019702,
79
- "Name Guy Man",
80
- 1,
81
- "Sauceror",
82
- ).full();
83
-
84
- expectNotNull(player);
85
- expect(player.avatar).toContain("<svg");
74
+ test("matches by id", () => {
75
+ const player = new Player(client, 1197090, "gausie");
76
+ expect(player.matches(1197090)).toBe(true);
77
+ expect(player.matches(999)).toBe(false);
86
78
  });
87
79
 
88
- test("Can parse an avatar when the player has been painted gold", async () => {
89
- vi.mocked(text).mockResolvedValueOnce(
90
- await loadFixture(__dirname, "showplayer_golden_gun.html"),
91
- );
92
-
93
- const player = await new Player(
94
- client,
95
- 1197090,
96
- "gAUSIE",
97
- 15,
98
- "Sauceror",
99
- ).full();
100
-
101
- expectNotNull(player);
102
- expect(player.avatar).toContain("<svg");
80
+ test("matches by name case-insensitively", () => {
81
+ const player = new Player(client, 1197090, "gausie");
82
+ expect(player.matches("gausie")).toBe(true);
83
+ expect(player.matches("GAUSIE")).toBe(true);
84
+ expect(player.matches("other")).toBe(false);
103
85
  });
104
86
 
105
- test("Can resolve KoL images", () => {
106
- expect(resolveKoLImage("/iii/otherimages/classav31_f.gif")).toBe(
107
- "https://s3.amazonaws.com/images.kingdomofloathing.com/otherimages/classav31_f.gif",
108
- );
109
- expect(resolveKoLImage("/itemimages/oaf.gif")).toBe(
110
- "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
111
- );
112
- expect(
113
- resolveKoLImage(
114
- "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
115
- ),
116
- ).toBe(
117
- "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
118
- );
87
+ test("Profiled.fetch() returns itself", async () => {
88
+ const profiled = new Player.Profiled(client, {
89
+ id: 1,
90
+ name: "test",
91
+ level: 15,
92
+ kolClass: "Sauceror",
93
+ avatar: "test.gif",
94
+ ascensions: 100,
95
+ trophies: 5,
96
+ tattoos: 3,
97
+ favoriteFood: null,
98
+ favoriteBooze: null,
99
+ createdDate: new Date(),
100
+ lastLogin: new Date(),
101
+ hasDisplayCase: false,
102
+ });
103
+ expect(await profiled.fetch()).toBe(profiled);
119
104
  });
120
105
  });
106
+
107
+ test("Can resolve KoL images", () => {
108
+ expect(resolveKoLImage("/iii/otherimages/classav31_f.gif")).toBe(
109
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/otherimages/classav31_f.gif",
110
+ );
111
+ expect(resolveKoLImage("/itemimages/oaf.gif")).toBe(
112
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
113
+ );
114
+ expect(
115
+ resolveKoLImage(
116
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
117
+ ),
118
+ ).toBe(
119
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
120
+ );
121
+ });
package/src/Player.ts CHANGED
@@ -1,215 +1,146 @@
1
- import { Client } from "./Client.js";
1
+ import type { Client } from "./Client.js";
2
2
  import { generateAvatarSvg } from "./utils/avatar.js";
3
3
  import { parsePlayerDate } from "./utils/utils.js";
4
4
 
5
- export type If<
6
- Value extends boolean,
7
- TrueResult,
8
- FalseResult = null,
9
- > = Value extends true
10
- ? TrueResult
11
- : Value extends false
12
- ? FalseResult
13
- : TrueResult | FalseResult;
14
-
15
- export class Player<IsFull extends boolean = boolean> {
16
- #client: Client;
17
-
5
+ export type ProfileData = {
18
6
  id: number;
19
7
  name: string;
8
+ level: number;
9
+ kolClass: string;
10
+ avatar: string;
11
+ ascensions: number;
12
+ trophies: number;
13
+ tattoos: number;
14
+ favoriteFood: string | null;
15
+ favoriteBooze: string | null;
16
+ createdDate: Date;
17
+ lastLogin: Date;
18
+ hasDisplayCase: boolean;
19
+ };
20
+
21
+ export class Player {
22
+ readonly id: number;
23
+ readonly name: string;
24
+ #client: Client;
20
25
 
21
- level: If<IsFull, number>;
22
- kolClass: If<IsFull, string>;
23
- avatar: If<IsFull, string>;
24
- ascensions: If<IsFull, number>;
25
- trophies: If<IsFull, number>;
26
- tattoos: If<IsFull, number>;
27
- favoriteFood: If<IsFull, string | null>;
28
- favoriteBooze: If<IsFull, string | null>;
29
- createdDate: If<IsFull, Date>;
30
- lastLogin: If<IsFull, Date>;
31
- hasDisplayCase: If<IsFull, boolean>;
32
-
33
- constructor(
34
- client: Client,
35
- id: number,
36
- name: string,
37
- level: number | null = null,
38
- kolClass: string | null = null,
39
- ) {
26
+ constructor(client: Client, id: number, name: string) {
40
27
  this.#client = client;
41
28
  this.id = id;
42
29
  this.name = name;
43
- this.level = level as If<IsFull, number>;
44
- this.kolClass = kolClass as If<IsFull, string>;
45
- this.avatar = null as If<IsFull, string>;
46
- this.ascensions = null as If<IsFull, number>;
47
- this.trophies = null as If<IsFull, number>;
48
- this.tattoos = null as If<IsFull, number>;
49
- this.favoriteFood = null as If<IsFull, string>;
50
- this.favoriteBooze = null as If<IsFull, string>;
51
- this.createdDate = null as If<IsFull, Date>;
52
- this.lastLogin = null as If<IsFull, Date>;
53
- this.hasDisplayCase = null as If<IsFull, boolean>;
54
30
  }
55
31
 
56
- isFull(): this is Player<true> {
57
- return this.createdDate !== null;
32
+ async fetch(): Promise<Player.Profiled | null> {
33
+ if (this instanceof Player.Profiled) return this;
34
+ return this.#client.players.fetch(this.id);
58
35
  }
59
36
 
60
- isPartial(): this is Player<false> {
61
- return this.createdDate === null;
37
+ async isOnline(): Promise<boolean> {
38
+ const response = await this.#client.useChatMacro(`/whois ${this.name}`);
39
+ return response.output.includes("This player is currently online");
62
40
  }
63
41
 
64
- static async getNameFromId(
65
- client: Client,
66
- id: number,
67
- ): Promise<string | null> {
68
- try {
69
- const profile = await client.fetchText("submitnewchat.php", {
70
- searchParams: { graf: `/whois ${id}` },
71
- });
72
- const name = profile.match(/<a.*?><b.*?>(.*?) \(#(\d+)\)<\/b><\/a>/)?.[1];
73
- return name ?? null;
74
- } catch {
75
- return null;
42
+ matches(identifier: string | number): boolean {
43
+ const id = Number(identifier);
44
+ if (!Number.isNaN(id) || typeof identifier === "number") {
45
+ return this.id === id;
76
46
  }
47
+ return this.name.toLowerCase() === identifier.toLowerCase();
77
48
  }
78
49
 
79
- static async fromName(
80
- client: Client,
81
- name: string,
82
- ): Promise<Player<false> | null> {
83
- try {
84
- const matcher =
85
- /<tr><td class=small><b><a target=mainpane href="showplayer\.php\?who=(?<playerId>\d+)">(?<playerName>[^<]+)<\/a><\/b>.*?<\/td><td valign=top class=small>\d*<\/td><td valign=top class=small>(?:<img src=".*?">|(?<level>\d+))<\/td><td class=small valign=top>(?<class>[^<]*)<\/td><\/tr>/i;
86
- const search = await client.fetchText("searchplayer.php", {
87
- searchParams: {
88
- searchstring: name.replace(/_/g, "\\_"),
89
- searching: "Yep.",
90
- for: "",
91
- startswith: 1,
92
- hardcoreonly: 0,
93
- },
94
- });
95
- const match = matcher.exec(search)?.groups;
96
-
97
- if (!match) {
98
- return null;
99
- }
100
-
101
- const clazz = match.level ? match.class : "Astral Spirit";
102
-
103
- return new Player(
104
- client,
105
- Number(match.playerId),
106
- match.playerName,
107
- parseInt(match.level) || 0,
108
- clazz,
109
- );
110
- } catch (error) {
111
- return null;
112
- }
50
+ toString(): string {
51
+ return `${this.name} (#${this.id})`;
113
52
  }
114
53
 
115
- static async fromId(
116
- client: Client,
117
- id: number,
118
- ): Promise<Player<false> | null> {
119
- const name = await Player.getNameFromId(client, id);
120
- if (!name) return null;
121
- return await Player.fromName(client, name);
54
+ static parseSearch(
55
+ html: string,
56
+ ): { id: number; name: string; level: number; kolClass: string } | null {
57
+ const matcher =
58
+ /<tr><td class=small><b><a target=mainpane href="showplayer\.php\?who=(?<playerId>\d+)">(?<playerName>[^<]+)<\/a><\/b>.*?<\/td><td valign=top class=small>\d*<\/td><td valign=top class=small>(?:<img src=".*?">|(?<level>\d+))<\/td><td class=small valign=top>(?<class>[^<]*)<\/td><\/tr>/i;
59
+ const match = matcher.exec(html)?.groups;
60
+ if (!match) return null;
61
+
62
+ return {
63
+ id: Number(match.playerId),
64
+ name: match.playerName,
65
+ level: parseInt(match.level) || 0,
66
+ kolClass: match.level ? match.class : "Astral Spirit",
67
+ };
122
68
  }
123
69
 
124
- static async from(
125
- client: Client,
126
- identifier: string | number,
127
- ): Promise<Player<false> | null> {
128
- const id = Number(identifier);
129
-
130
- if (!Number.isNaN(id) || typeof identifier === "number") {
131
- return await Player.fromId(client, id);
132
- }
133
-
134
- return await Player.fromName(client, identifier);
70
+ static parseNameFromWhois(html: string): string | null {
71
+ return html.match(/<a.*?><b.*?>(.*?) \(#(\d+)\)<\/b><\/a>/)?.[1] ?? null;
135
72
  }
73
+ }
136
74
 
137
- matchesIdentifier(identifier: string | number) {
138
- const id = Number(identifier);
139
- if (!Number.isNaN(id) || typeof identifier === "number") {
140
- return this.id === identifier;
75
+ export namespace Player {
76
+ export class Profiled extends Player {
77
+ readonly level: number;
78
+ readonly kolClass: string;
79
+ readonly avatar: string;
80
+ readonly ascensions: number;
81
+ readonly trophies: number;
82
+ readonly tattoos: number;
83
+ readonly favoriteFood: string | null;
84
+ readonly favoriteBooze: string | null;
85
+ readonly createdDate: Date;
86
+ readonly lastLogin: Date;
87
+ readonly hasDisplayCase: boolean;
88
+
89
+ constructor(client: Client, data: ProfileData) {
90
+ super(client, data.id, data.name);
91
+ this.level = data.level;
92
+ this.kolClass = data.kolClass;
93
+ this.avatar = data.avatar;
94
+ this.ascensions = data.ascensions;
95
+ this.trophies = data.trophies;
96
+ this.tattoos = data.tattoos;
97
+ this.favoriteFood = data.favoriteFood;
98
+ this.favoriteBooze = data.favoriteBooze;
99
+ this.createdDate = data.createdDate;
100
+ this.lastLogin = data.lastLogin;
101
+ this.hasDisplayCase = data.hasDisplayCase;
141
102
  }
142
103
 
143
- return this.name.toLowerCase() === identifier.toLowerCase();
144
- }
145
-
146
- async full(): Promise<Player<true> | null> {
147
- const t = this as unknown as Player<true>;
148
-
149
- if (this.isFull()) return this;
104
+ override async fetch(): Promise<Player.Profiled> {
105
+ return this;
106
+ }
150
107
 
151
- try {
152
- const profile = await this.#client.fetchText("showplayer.php", {
153
- searchParams: {
154
- who: this.id,
155
- },
156
- });
157
- const header = profile.match(
108
+ static async parseProfile(
109
+ html: string,
110
+ ): Promise<Omit<ProfileData, "id" | "name" | "level" | "kolClass"> | null> {
111
+ const header = html.match(
158
112
  /<center><table><tr><td><center>.*?<img.*?src="(.*?)".*?<b>([^>]*?)<\/b> \(#(\d+)\)<br>/,
159
113
  );
160
114
  if (!header) return null;
161
115
 
162
- t.avatar = (await generateAvatarSvg(profile)) || header[1];
163
-
164
- t.ascensions =
165
- Number(
166
- profile
167
- .match(/>Ascensions<\/a>:<\/b><\/td><td>(.*?)<\/td>/)?.[1]
168
- ?.replace(/,/g, ""),
169
- ) || 0;
170
-
171
- t.trophies = Number(
172
- profile.match(/>Trophies Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
173
- 0,
174
- );
175
-
176
- t.tattoos = Number(
177
- profile.match(/>Tattoos Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
178
- 0,
179
- );
180
-
181
- t.favoriteFood =
182
- profile.match(/>Favorite Food:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ?? null;
183
- t.favoriteBooze =
184
- profile.match(/>Favorite Booze:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
185
- null;
186
-
187
- t.createdDate = parsePlayerDate(
188
- profile.match(/>Account Created:<\/b><\/td><td>(.*?)<\/td>/)?.[1],
189
- );
190
-
191
- t.lastLogin = parsePlayerDate(
192
- profile.match(/>Last Login:<\/b><\/td><td>(.*?)<\/td>/)?.[1],
193
- );
194
-
195
- t.hasDisplayCase =
196
- profile.match(/Display Case<\/b><\/a> in the Museum<\/td>/) !== null;
197
-
198
- return t;
199
- } catch (error) {
200
- console.error(error);
201
- return null;
116
+ return {
117
+ avatar: (await generateAvatarSvg(html)) || header[1],
118
+ ascensions:
119
+ Number(
120
+ html
121
+ .match(/>Ascensions<\/a>:<\/b><\/td><td>(.*?)<\/td>/)?.[1]
122
+ ?.replace(/,/g, ""),
123
+ ) || 0,
124
+ trophies: Number(
125
+ html.match(/>Trophies Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
126
+ 0,
127
+ ),
128
+ tattoos: Number(
129
+ html.match(/>Tattoos Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ?? 0,
130
+ ),
131
+ favoriteFood:
132
+ html.match(/>Favorite Food:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ?? null,
133
+ favoriteBooze:
134
+ html.match(/>Favorite Booze:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ?? null,
135
+ createdDate: parsePlayerDate(
136
+ html.match(/>Account Created:<\/b><\/td><td>(.*?)<\/td>/)?.[1],
137
+ ),
138
+ lastLogin: parsePlayerDate(
139
+ html.match(/>Last Login:<\/b><\/td><td>(.*?)<\/td>/)?.[1],
140
+ ),
141
+ hasDisplayCase:
142
+ html.match(/Display Case<\/b><\/a> in the Museum<\/td>/) !== null,
143
+ };
202
144
  }
203
145
  }
204
-
205
- async isOnline() {
206
- const response = await this.#client.useChatMacro(`/whois ${this.name}`);
207
- return (
208
- response?.output.includes("This player is currently online") ?? false
209
- );
210
- }
211
-
212
- toString() {
213
- return `${this.name} (#${this.id})`;
214
- }
215
146
  }
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "vitest";
2
+
3
+ import { loadFixture } from "../testUtils.js";
4
+ import { parseScores } from "./AutomatedFuture.js";
5
+
6
+ test("Can read scores", async () => {
7
+ const page = await loadFixture(import.meta.dirname, "automated_future.html");
8
+
9
+ const scores = parseScores(page);
10
+
11
+ expect(scores).toHaveProperty("solenoids", 1001268);
12
+ expect(scores).toHaveProperty("bearings", 1001028);
13
+ });
14
+
15
+ test("Returns null if scores cannot be read", () => {
16
+ const scores = parseScores("");
17
+
18
+ expect(scores).toBe(null);
19
+ });
@@ -0,0 +1,46 @@
1
+ import type { Client } from "../Client.js";
2
+
3
+ export type TTTScores = {
4
+ solenoids: number;
5
+ bearings: number;
6
+ };
7
+
8
+ export function parseScores(page: string): TTTScores | null {
9
+ const pattern = /title='(-?\d+)' href=adventure.php\?snarfblat=(581|582)/gs;
10
+ const matches = [...page.matchAll(pattern)];
11
+
12
+ if (matches.length !== 2) return null;
13
+
14
+ const scores = matches.reduce<Record<string, number>>(
15
+ (acc, m) => ({ ...acc, [m[2]]: Number(m[1]) }),
16
+ {},
17
+ );
18
+
19
+ return {
20
+ solenoids: scores["581"] ?? 0,
21
+ bearings: scores["582"] ?? 0,
22
+ };
23
+ }
24
+
25
+ export class AutomatedFuture {
26
+ #client: Client;
27
+
28
+ constructor(client: Client) {
29
+ this.#client = client;
30
+ }
31
+
32
+ async visit(): Promise<string> {
33
+ return await this.#client.actionMutex.runExclusive(async () => {
34
+ await this.#client.fetchText("town.php");
35
+ return this.#client.fetchText("place.php", {
36
+ query: { whichplace: "twitch" },
37
+ });
38
+ });
39
+ }
40
+
41
+ async getScores(): Promise<TTTScores | null> {
42
+ const page = await this.visit();
43
+ if (page.includes("faded back into the swirling mists")) return null;
44
+ return parseScores(page);
45
+ }
46
+ }
@@ -0,0 +1,20 @@
1
+ import { expect, test } from "vitest";
2
+
3
+ import { loadFixture } from "../testUtils.js";
4
+ import { Bookmobile } from "./Bookmobile.js";
5
+
6
+ test("Can read relevant information", async () => {
7
+ const page = await loadFixture(import.meta.dirname, "bookmobile_spooky.html");
8
+
9
+ const info = Bookmobile.parse(page);
10
+
11
+ expect(info).toHaveProperty("copies", "quite a few");
12
+ expect(info).toHaveProperty("title", "Pocket Guide to Mild Evil");
13
+ expect(info).toHaveProperty("price", 9932000);
14
+ });
15
+
16
+ test("Returns null if data cannot be read", () => {
17
+ const info = Bookmobile.parse("");
18
+
19
+ expect(info).toBe(null);
20
+ });
@@ -0,0 +1,66 @@
1
+ import type { Client } from "../Client.js";
2
+
3
+ export type BookmobileStatus = {
4
+ copies: string;
5
+ title: string;
6
+ price: number;
7
+ };
8
+
9
+ export class BookmobileNotInTownError extends Error {
10
+ constructor() {
11
+ super("The Bookmobile is not in town");
12
+ Object.setPrototypeOf(this, new.target.prototype);
13
+ }
14
+ }
15
+
16
+ export class BookmobileParseError extends Error {
17
+ constructor() {
18
+ super("Could not parse the Bookmobile page");
19
+ Object.setPrototypeOf(this, new.target.prototype);
20
+ }
21
+ }
22
+
23
+ export class Bookmobile {
24
+ #client: Client;
25
+
26
+ constructor(client: Client) {
27
+ this.#client = client;
28
+ }
29
+
30
+ async visit(): Promise<BookmobileStatus> {
31
+ const page = await this.#client.actionMutex.runExclusive(async () => {
32
+ await this.#client.fetchText("town.php");
33
+ const p = await this.#client.fetchText("place.php", {
34
+ query: { whichplace: "town_market", action: "town_bookmobile" },
35
+ });
36
+ if (p.includes("name=whichchoice")) {
37
+ await this.#client.fetchText("choice.php", {
38
+ form: {
39
+ whichchoice: 1200,
40
+ option: 2,
41
+ },
42
+ });
43
+ }
44
+ return p;
45
+ });
46
+
47
+ if (!page.includes("name=whichchoice")) {
48
+ throw new BookmobileNotInTownError();
49
+ }
50
+
51
+ const result = Bookmobile.parse(page);
52
+ if (!result) throw new BookmobileParseError();
53
+ return result;
54
+ }
55
+
56
+ static parse(page: string): BookmobileStatus | null {
57
+ const pattern =
58
+ /this week, I've got (.*?) copies of(?: the)? <b>(.*?)<\/b>.*?<b>([0-9,]+) Meat<\/b>/s;
59
+ const match = page.match(pattern);
60
+
61
+ if (!match) return null;
62
+ const [, copies, title, price] = match;
63
+
64
+ return { copies, title, price: Number(price.replaceAll(",", "")) };
65
+ }
66
+ }