kol.js 0.0.2 → 0.1.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.
Files changed (58) hide show
  1. package/package.json +7 -6
  2. package/src/Cache.ts +33 -0
  3. package/src/Client.test.ts +206 -0
  4. package/src/Client.ts +506 -0
  5. package/src/Player.test.ts +101 -0
  6. package/src/Player.ts +209 -0
  7. package/src/__fixtures__/backoffice_prices_alien_meat.html +46 -0
  8. package/src/__fixtures__/backoffice_prices_lov_elephant.html +30 -0
  9. package/src/__fixtures__/backoffice_prices_magical_mystery_juice.html +30 -0
  10. package/src/__fixtures__/backoffice_prices_tofurkey_leg.html +48 -0
  11. package/src/__fixtures__/backoffice_prices_turtle_wax_greaves.html +42 -0
  12. package/src/__fixtures__/backoffice_prices_turtle_wax_helmet.html +42 -0
  13. package/src/__fixtures__/backoffice_prices_turtle_wax_shield.html +47 -0
  14. package/src/__fixtures__/desc_effect_pasta_oneness.html +31 -0
  15. package/src/__fixtures__/desc_effect_the_visible_adventurer.html +31 -0
  16. package/src/__fixtures__/desc_item_alien_meat.html +38 -0
  17. package/src/__fixtures__/desc_item_hypodermic_needle.html +39 -0
  18. package/src/__fixtures__/desc_item_lov_elephant.html +39 -0
  19. package/src/__fixtures__/desc_item_magical_mystery_juice.html +39 -0
  20. package/src/__fixtures__/desc_item_mosquito_larva.html +39 -0
  21. package/src/__fixtures__/desc_item_tofurkey_leg.html +38 -0
  22. package/src/__fixtures__/desc_item_turtle_wax_shield.html +79 -0
  23. package/src/__fixtures__/desc_skill_impetuous_sauciness.html +32 -0
  24. package/src/__fixtures__/desc_skill_overload_discarded_refridgerator.html +32 -0
  25. package/src/__fixtures__/familiar_in_standard_run.html +184 -0
  26. package/src/__fixtures__/searchplayer_mad_carew.html +53 -0
  27. package/src/__fixtures__/showplayer_dependence_day.html +47 -0
  28. package/src/__fixtures__/showplayer_golden_gun.html +28 -0
  29. package/src/__fixtures__/showplayer_regular.html +56 -0
  30. package/{dist/index.d.ts → src/index.ts} +1 -0
  31. package/src/testUtils.ts +13 -0
  32. package/src/utils/avatar.ts +90 -0
  33. package/src/utils/kmail.ts +48 -0
  34. package/src/utils/leaderboard.ts +84 -0
  35. package/src/utils/utils.ts +33 -0
  36. package/dist/Cache.d.ts +0 -11
  37. package/dist/Cache.js +0 -26
  38. package/dist/Client.d.ts +0 -84
  39. package/dist/Client.js +0 -343
  40. package/dist/Client.test.d.ts +0 -1
  41. package/dist/Client.test.js +0 -162
  42. package/dist/Player.d.ts +0 -28
  43. package/dist/Player.js +0 -136
  44. package/dist/Player.test.d.ts +0 -1
  45. package/dist/Player.test.js +0 -48
  46. package/dist/index.js +0 -3
  47. package/dist/testUtils.d.ts +0 -2
  48. package/dist/testUtils.js +0 -10
  49. package/dist/utils/avatar.d.ts +0 -1
  50. package/dist/utils/avatar.js +0 -70
  51. package/dist/utils/kmail.d.ts +0 -32
  52. package/dist/utils/kmail.js +0 -1
  53. package/dist/utils/leaderboard.d.ts +0 -16
  54. package/dist/utils/leaderboard.js +0 -56
  55. package/dist/utils/utils.d.ts +0 -4
  56. package/dist/utils/utils.js +0 -27
  57. package/dist/utils/visit.d.ts +0 -3
  58. package/dist/utils/visit.js +0 -10
package/src/Client.ts ADDED
@@ -0,0 +1,506 @@
1
+ import { Mutex } from "async-mutex";
2
+ import { EventEmitter } from "node:events";
3
+ import TypedEventEmitter, { EventMap } from "typed-emitter";
4
+
5
+ import { sanitiseBlueText, wait } from "./utils/utils.js";
6
+ import { Player } from "./Player.js";
7
+ import { parseLeaderboard } from "./utils/leaderboard.js";
8
+ import {
9
+ ChatMessage,
10
+ KmailMessage,
11
+ KoLChatMessage,
12
+ KoLKmail,
13
+ KoLMessage,
14
+ isValidMessage,
15
+ } from "./utils/kmail.js";
16
+ import { PlayerCache } from "./Cache.js";
17
+ import { CookieJar } from "tough-cookie";
18
+ import got, { OptionsOfJSONResponseBody, OptionsOfTextResponseBody } from "got";
19
+
20
+ type TypedEmitter<T extends EventMap> = TypedEventEmitter.default<T>;
21
+
22
+ type MallPrice = {
23
+ formattedMallPrice: string;
24
+ formattedLimitedMallPrice: string;
25
+ formattedMinPrice: string;
26
+ mallPrice: number;
27
+ limitedMallPrice: number;
28
+ minPrice: number | null;
29
+ };
30
+
31
+ type Events = {
32
+ kmail: (message: KoLMessage) => void;
33
+ whisper: (message: KoLMessage) => void;
34
+ system: (message: KoLMessage) => void;
35
+ public: (message: KoLMessage) => void;
36
+ rollover: () => void;
37
+ };
38
+
39
+ type Familiar = {
40
+ id: number;
41
+ name: string;
42
+ image: string;
43
+ };
44
+
45
+ export class Client extends (EventEmitter as new () => TypedEmitter<Events>) {
46
+ actionMutex = new Mutex();
47
+ session = got.extend({
48
+ cookieJar: new CookieJar(),
49
+ prefixUrl: "https://www.kingdomofloathing.com",
50
+ handlers: [
51
+ (options, next) => {
52
+ if (options.form) {
53
+ if (options.form.pwd !== false) options.form.pwd = this.#pwd;
54
+ }
55
+
56
+ if (options.searchParams) {
57
+ const searchParams = options.searchParams as URLSearchParams;
58
+ if (searchParams.get("pwd") !== "false") searchParams.set("pwd", this.#pwd);
59
+ }
60
+
61
+ return next(options);
62
+ },
63
+ ],
64
+ });
65
+ players = new PlayerCache(this);
66
+
67
+ #username: string;
68
+ #password: string;
69
+ #isRollover = false;
70
+ #chatBotStarted = false;
71
+ #pwd = "";
72
+
73
+ private lastFetchedMessages = "0";
74
+ private postRolloverLatch = false;
75
+
76
+ constructor(username: string, password: string) {
77
+ super();
78
+ this.#username = username;
79
+ this.#password = password;
80
+ }
81
+
82
+ get username() {
83
+ return this.#username;
84
+ }
85
+
86
+ async fetchText(
87
+ path: string,
88
+ options: OptionsOfTextResponseBody = {},
89
+ fallback?: string,
90
+ ): Promise<string> {
91
+ // With no pwd, try to log in
92
+ if (!this.#pwd && !(await this.login())) return fallback ?? "";
93
+
94
+ // Make the request
95
+ const response = await this.session(path, {
96
+ method: "POST",
97
+ ...options,
98
+ responseType: "text",
99
+ });
100
+
101
+ // If we've been redirected to the login page, clear the pwd and try again
102
+ if (response.url.includes("/login.php")) {
103
+ this.#pwd = "";
104
+ return this.fetchText(path, options);
105
+ }
106
+
107
+ return response.body;
108
+ }
109
+
110
+ async fetchJson<Result>(
111
+ path: string,
112
+ options: OptionsOfJSONResponseBody = {},
113
+ fallback?: Result,
114
+ ): Promise<Result | null> {
115
+ if (!(await this.login())) return fallback ?? null;
116
+
117
+ // Make the request
118
+ const response = await this.session(path, {
119
+ ...options,
120
+ responseType: "json",
121
+ });
122
+
123
+ // If we've been redirected to the login page, clear the pwd and try again
124
+ if (response.url.includes("/login.php")) {
125
+ this.#pwd = "";
126
+ return this.fetchJson(path, options);
127
+ }
128
+
129
+ return response.body as Result;
130
+ }
131
+
132
+ async login(): Promise<boolean> {
133
+ if (await this.checkLoggedIn()) return true;
134
+ if (this.#isRollover) return false;
135
+ try {
136
+ await this.session
137
+ .post("login.php", {
138
+ form: {
139
+ loggingin: "Yup.",
140
+ loginname: this.#username,
141
+ password: this.#password,
142
+ secure: "0",
143
+ submitbutton: "Log In",
144
+ },
145
+ })
146
+ .text();
147
+
148
+ if (!(await this.checkLoggedIn())) return false;
149
+
150
+ if (this.postRolloverLatch) {
151
+ this.postRolloverLatch = false;
152
+ this.emit("rollover");
153
+ }
154
+
155
+ return true;
156
+ } catch (error) {
157
+ console.log("error", error);
158
+ // Login failed, let's check if it is due to rollover
159
+ await this.#checkForRollover();
160
+ return false;
161
+ }
162
+ }
163
+
164
+ isRollover() {
165
+ return this.#isRollover;
166
+ }
167
+
168
+ async checkLoggedIn(): Promise<boolean> {
169
+ try {
170
+ const api = await this.session
171
+ .get("api.php", {
172
+ searchParams: { what: "status", for: `${this.#username} bot` },
173
+ })
174
+ .json<{ pwd: string }>();
175
+ this.#pwd = api.pwd;
176
+ return true;
177
+ } catch (error) {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ async #checkForRollover() {
183
+ const isRollover =
184
+ /The system is currently down for nightly maintenance/.test(
185
+ await this.fetchText("/"),
186
+ );
187
+
188
+ if (this.#isRollover && !isRollover) {
189
+ // Set the post-rollover latch so the bot can react on next log in.
190
+ this.postRolloverLatch = true;
191
+ }
192
+
193
+ this.#isRollover = isRollover;
194
+
195
+ if (this.#isRollover) {
196
+ // Rollover appears to be in progress. Check again in one minute.
197
+ setTimeout(() => this.#checkForRollover(), 60000);
198
+ }
199
+ }
200
+
201
+ async startChatBot() {
202
+ if (this.#chatBotStarted) return;
203
+ await this.useChatMacro("/join talkie");
204
+ this.loopChatBot();
205
+ this.#chatBotStarted = true;
206
+ }
207
+
208
+ private async loopChatBot() {
209
+ await Promise.all([this.checkMessages(), this.checkKmails()]);
210
+ await wait(3000);
211
+ await this.loopChatBot();
212
+ }
213
+
214
+ async checkMessages() {
215
+ const newChatMessagesResponse = await this.fetchJson<{
216
+ last: string;
217
+ msgs: KoLChatMessage[];
218
+ }>("newchatmessages.php", {
219
+ searchParams: {
220
+ j: 1,
221
+ lasttime: this.lastFetchedMessages,
222
+ },
223
+ });
224
+
225
+ if (!newChatMessagesResponse || typeof newChatMessagesResponse !== "object")
226
+ return;
227
+
228
+ this.lastFetchedMessages = newChatMessagesResponse["last"];
229
+
230
+ newChatMessagesResponse["msgs"]
231
+ .filter(isValidMessage)
232
+ .map(
233
+ (msg): ChatMessage => ({
234
+ type: msg.type as ChatMessage["type"],
235
+ who: new Player(this, Number(msg.who.id), msg.who.name),
236
+ msg: msg.msg,
237
+ time: new Date(Number(msg.time) * 1000),
238
+ }),
239
+ )
240
+ .forEach((message) => {
241
+ switch (message.type) {
242
+ case "public":
243
+ return void this.emit("public", message);
244
+ case "private":
245
+ return void this.emit("whisper", message);
246
+ case "system":
247
+ return void this.emit("system", message);
248
+ }
249
+ });
250
+ }
251
+
252
+ async fetchKmails(): Promise<KmailMessage[]> {
253
+ const kmails = await this.fetchJson<KoLKmail[]>("api.php", {
254
+ searchParams: {
255
+ what: "kmail",
256
+ for: `${this.#username} bot`,
257
+ },
258
+ });
259
+
260
+ if (!Array.isArray(kmails) || kmails.length === 0) return [];
261
+
262
+ return kmails.map((msg: KoLKmail) => ({
263
+ id: Number(msg.id),
264
+ type: "kmail" as const,
265
+ who: new Player(this, Number(msg.fromid), msg.fromname),
266
+ msg: msg.message,
267
+ time: new Date(Number(msg.azunixtime) * 1000),
268
+ }));
269
+ }
270
+
271
+ async deleteKmails(ids: number[]) {
272
+ await this.fetchText("messages.php", {
273
+ searchParams: {
274
+ the_action: "delete",
275
+ box: "Inbox",
276
+ ...Object.fromEntries(ids.map((id) => [`sel${id}`, "on"])),
277
+ pwd: true,
278
+ },
279
+ });
280
+ }
281
+
282
+ async checkKmails() {
283
+ const kmails = await this.fetchKmails();
284
+ await this.deleteKmails(kmails.map((k) => k.id));
285
+ kmails.forEach((m) => this.emit("kmail", m));
286
+ }
287
+
288
+ async sendChat(message: string) {
289
+ return await this.fetchJson<{ output: string; msgs: string[] }>(
290
+ "submitnewchat.php",
291
+ {
292
+ searchParams: {
293
+ graf: message,
294
+ j: 1,
295
+ },
296
+ },
297
+ );
298
+ }
299
+
300
+ async useChatMacro(macro: string) {
301
+ return await this.sendChat(`/clan ${macro}`);
302
+ }
303
+
304
+ async whisper(recipientId: number, message: string) {
305
+ await this.useChatMacro(`/w ${recipientId} ${message}`);
306
+ }
307
+
308
+ async kmail(recipientId: number, message: string) {
309
+ await this.fetchText("sendmessage.php", {
310
+ searchParams: {
311
+ action: "send",
312
+ j: 1,
313
+ towho: recipientId,
314
+ contact: 0,
315
+ message: message,
316
+ howmany1: 1,
317
+ whichitem1: 0,
318
+ sendmeat: 0,
319
+ },
320
+ });
321
+ }
322
+
323
+ async getMallPrice(itemId: number): Promise<MallPrice> {
324
+ const prices = await this.fetchText("backoffice.php", {
325
+ searchParams: {
326
+ action: "prices",
327
+ pwd: true,
328
+ ajax: 1,
329
+ iid: itemId,
330
+ },
331
+ });
332
+ const unlimitedMatch = prices.match(
333
+ /<td>unlimited:<\/td><td><b>(?<unlimitedPrice>[\d,]+)/,
334
+ );
335
+ const limitedMatch = prices.match(
336
+ /<td>limited:<\/td><td><b>(?<limitedPrice>[\d,]+)/,
337
+ );
338
+ const unlimitedPrice = unlimitedMatch
339
+ ? parseInt(unlimitedMatch[1].replace(/,/g, ""))
340
+ : 0;
341
+ const limitedPrice = limitedMatch
342
+ ? parseInt(limitedMatch[1].replace(/,/g, ""))
343
+ : 0;
344
+ let minPrice = limitedMatch ? limitedPrice : null;
345
+ minPrice = unlimitedMatch
346
+ ? !minPrice || unlimitedPrice < minPrice
347
+ ? unlimitedPrice
348
+ : minPrice
349
+ : minPrice;
350
+ const formattedMinPrice = minPrice
351
+ ? (minPrice === unlimitedPrice
352
+ ? unlimitedMatch?.[1]
353
+ : limitedMatch?.[1]) ?? ""
354
+ : "";
355
+ return {
356
+ mallPrice: unlimitedPrice,
357
+ limitedMallPrice: limitedPrice,
358
+ formattedMinPrice: formattedMinPrice,
359
+ minPrice: minPrice,
360
+ formattedMallPrice: unlimitedMatch ? unlimitedMatch[1] : "",
361
+ formattedLimitedMallPrice: limitedMatch ? limitedMatch[1] : "",
362
+ };
363
+ }
364
+
365
+ async getItemDescription(descId: number): Promise<{
366
+ melting: boolean;
367
+ singleEquip: boolean;
368
+ blueText: string;
369
+ effect?: {
370
+ name: string;
371
+ duration: number;
372
+ descid: string;
373
+ };
374
+ }> {
375
+ const description = await this.fetchText("desc_item.php", {
376
+ searchParams: {
377
+ whichitem: descId,
378
+ },
379
+ });
380
+ const blueText = description.match(
381
+ /<center>\s*<b>\s*<font color="?[\w]+"?>(?<description>[\s\S]+)<\/center>/i,
382
+ );
383
+ const effect = description.match(
384
+ /Effect: \s?<b>\s?<a[^>]+href="desc_effect\.php\?whicheffect=(?<descid>[^"]+)[^>]+>(?<effect>[\s\S]+)<\/a>[^(]+\((?<duration>[\d]+)/,
385
+ );
386
+ const melting = description.match(
387
+ /This item will disappear at the end of the day\./,
388
+ );
389
+ const singleEquip = description.match(
390
+ / You may not equip more than one of these at a time\./,
391
+ );
392
+
393
+ return {
394
+ melting: !!melting,
395
+ singleEquip: !!singleEquip,
396
+ blueText: sanitiseBlueText(blueText?.groups?.description),
397
+ effect: effect?.groups
398
+ ? {
399
+ name: effect.groups?.name,
400
+ duration: Number(effect.groups?.duration) || 0,
401
+ descid: effect.groups?.descid,
402
+ }
403
+ : undefined,
404
+ };
405
+ }
406
+
407
+ async getEffectDescription(descId: string): Promise<{ blueText: string }> {
408
+ const description = await this.fetchText("desc_effect.php", {
409
+ searchParams: {
410
+ whicheffect: descId,
411
+ },
412
+ });
413
+ const blueText = description.match(
414
+ /<center><font color="?[\w]+"?>(?<description>[\s\S]+)<\/div>/m,
415
+ );
416
+ return { blueText: sanitiseBlueText(blueText?.groups?.description) };
417
+ }
418
+
419
+ async getSkillDescription(id: number): Promise<{ blueText: string }> {
420
+ const description = await this.fetchText("desc_skill.php", {
421
+ searchParams: {
422
+ whichskill: String(id),
423
+ },
424
+ });
425
+
426
+ const blueText = description.match(
427
+ /<blockquote[\s\S]+<[Cc]enter>(?<description>[\s\S]+)<\/[Cc]enter>/,
428
+ );
429
+ return { blueText: sanitiseBlueText(blueText?.groups?.description) };
430
+ }
431
+
432
+ async joinClan(id: number): Promise<boolean> {
433
+ const result = await this.fetchText("showclan.php", {
434
+ searchParams: {
435
+ whichclan: id,
436
+ action: "joinclan",
437
+ confirm: "on",
438
+ pwd: true,
439
+ },
440
+ });
441
+ return (
442
+ result.includes("clanhalltop.gif") ||
443
+ result.includes("a clan you're already in")
444
+ );
445
+ }
446
+
447
+ async addToWhitelist(playerId: number, clanId: number): Promise<boolean> {
448
+ return await this.actionMutex.runExclusive(async () => {
449
+ if (!(await this.joinClan(clanId))) return false;
450
+ await this.fetchText("clan_whitelist.php", {
451
+ searchParams: {
452
+ addwho: playerId,
453
+ level: 2,
454
+ title: "",
455
+ action: "add",
456
+ },
457
+ });
458
+ return true;
459
+ });
460
+ }
461
+
462
+ async getLeaderboard(leaderboardId: number) {
463
+ const page = await this.fetchText("museum.php", {
464
+ searchParams: {
465
+ floor: 1,
466
+ place: "leaderboards",
467
+ whichboard: leaderboardId,
468
+ },
469
+ });
470
+
471
+ return parseLeaderboard(page);
472
+ }
473
+
474
+ async useFamiliar(familiarId: number): Promise<boolean> {
475
+ const result = await this.fetchText("familiar.php", {
476
+ searchParams: {
477
+ action: "newfam",
478
+ newfam: familiarId.toFixed(0),
479
+ },
480
+ });
481
+
482
+ return result.includes(`var currentfam = ${familiarId};`);
483
+ }
484
+
485
+ async getFamiliars(): Promise<Familiar[]> {
486
+ const terrarium = await this.fetchText("familiar.php");
487
+ const matches = terrarium.matchAll(
488
+ /onClick='fam\((\d+)\)'(?:><img)? src=".*?\/(\w+\/\w+.(?:gif|png))".*?\d+-pound (.*?) \(/g,
489
+ );
490
+ const familiars = [...matches].map((m) => ({
491
+ id: Number(m[1]),
492
+ image: m[2],
493
+ name: m[3],
494
+ }));
495
+
496
+ if (terrarium.includes("fam(278)")) {
497
+ familiars.push({
498
+ id: 278,
499
+ image: "otherimages/righthandbody.png",
500
+ name: "Left-Hand Man",
501
+ });
502
+ }
503
+
504
+ return familiars;
505
+ }
506
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { resolveKoLImage } from "./utils/utils.js";
3
+ import { Player } from "./Player.js";
4
+ import { expectNotNull, loadFixture } from "./testUtils.js";
5
+ import { Client } from "./Client.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
+ test("Can search for a player by name", async () => {
21
+ vi.mocked(text).mockResolvedValueOnce(
22
+ await loadFixture(__dirname, "searchplayer_mad_carew.html"),
23
+ );
24
+
25
+ const player = await Player.fromName(client, "mad carew");
26
+
27
+ expectNotNull(player);
28
+
29
+ expect(player.id).toBe(263717);
30
+ // Learns correct capitalisation
31
+ expect(player.name).toBe("Mad Carew");
32
+ });
33
+
34
+ describe("Profile parsing", () => {
35
+ test("Can parse a profile picture", async () => {
36
+ vi.mocked(text).mockResolvedValueOnce(
37
+ await loadFixture(__dirname, "showplayer_regular.html"),
38
+ );
39
+
40
+ const player = await new Player(
41
+ client,
42
+ 2264486,
43
+ "SSBBHax",
44
+ 1,
45
+ "Sauceror",
46
+ ).full();
47
+
48
+ expectNotNull(player);
49
+ expect(player.avatar).toContain("<svg");
50
+ });
51
+
52
+ test("Can parse a profile picture on dependence day", async () => {
53
+ vi.mocked(text).mockResolvedValueOnce(
54
+ await loadFixture(__dirname, "showplayer_dependence_day.html"),
55
+ );
56
+
57
+ const player = await new Player(
58
+ client,
59
+ 3019702,
60
+ "Name Guy Man",
61
+ 1,
62
+ "Sauceror",
63
+ ).full();
64
+
65
+ expectNotNull(player);
66
+ expect(player.avatar).toContain("<svg");
67
+ });
68
+
69
+ test("Can parse an avatar when the player has been painted gold", async () => {
70
+ vi.mocked(text).mockResolvedValueOnce(
71
+ await loadFixture(__dirname, "showplayer_golden_gun.html"),
72
+ );
73
+
74
+ const player = await new Player(
75
+ client,
76
+ 1197090,
77
+ "gAUSIE",
78
+ 15,
79
+ "Sauceror",
80
+ ).full();
81
+
82
+ expectNotNull(player);
83
+ expect(player.avatar).toContain("<svg");
84
+ });
85
+
86
+ test("Can resolve KoL images", () => {
87
+ expect(resolveKoLImage("/iii/otherimages/classav31_f.gif")).toBe(
88
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/otherimages/classav31_f.gif",
89
+ );
90
+ expect(resolveKoLImage("/itemimages/oaf.gif")).toBe(
91
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
92
+ );
93
+ expect(
94
+ resolveKoLImage(
95
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
96
+ ),
97
+ ).toBe(
98
+ "https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif",
99
+ );
100
+ });
101
+ });