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.
- package/package.json +10 -5
- package/src/Client.test.ts +245 -64
- package/src/Client.ts +201 -191
- package/src/LoathingDate.test.ts +202 -0
- package/src/LoathingDate.ts +390 -0
- package/src/Player.test.ts +83 -82
- package/src/Player.ts +112 -181
- package/src/domains/AutomatedFuture.test.ts +19 -0
- package/src/domains/AutomatedFuture.ts +46 -0
- package/src/domains/Bookmobile.test.ts +20 -0
- package/src/domains/Bookmobile.ts +66 -0
- package/src/domains/ClanDungeon.ts +230 -0
- package/src/domains/Dreadsylvania.test.ts +424 -0
- package/src/domains/Dreadsylvania.ts +550 -0
- package/src/domains/Familiar.ts +82 -0
- package/src/domains/FloralMercantileExchange.test.ts +20 -0
- package/src/domains/FloralMercantileExchange.ts +51 -0
- package/src/{utils/leaderboard.test.ts → domains/Leaderboard.test.ts} +24 -4
- package/src/domains/Leaderboard.ts +173 -0
- package/src/domains/Players.test.ts +141 -0
- package/src/domains/Players.ts +108 -0
- package/src/domains/Raffle.test.ts +65 -0
- package/src/domains/Raffle.ts +60 -0
- package/src/domains/SkeletonOfCrimboPast.test.ts +55 -0
- package/src/domains/SkeletonOfCrimboPast.ts +38 -0
- package/src/domains/WardrobeOMatic.test.ts +141 -0
- package/src/domains/WardrobeOMatic.ts +650 -0
- package/src/domains/__fixtures__/automated_future.html +1 -0
- package/src/domains/__fixtures__/bookmobile_spooky.html +6 -0
- package/src/domains/__fixtures__/dread/cdr1-current.html +25 -0
- package/src/domains/__fixtures__/dread/cdr1-oldlogs-page0.html +25 -0
- package/src/domains/__fixtures__/dread/cdr2-current.html +25 -0
- package/src/domains/__fixtures__/dread/cdr2-oldlogs-page0.html +25 -0
- package/src/domains/__fixtures__/dread/raid-213013.html +24 -0
- package/src/domains/__fixtures__/dread/raid-217988.html +24 -0
- package/src/domains/__fixtures__/dread/raid-218029.html +24 -0
- package/src/domains/__fixtures__/dread/raid-218205.html +24 -0
- package/src/domains/__fixtures__/dread/raid-218286.html +24 -0
- package/src/domains/__fixtures__/dread/raid-218518.html +24 -0
- package/src/domains/__fixtures__/dread/raid-218519.html +24 -0
- package/src/domains/__fixtures__/flowers.html +229 -0
- package/src/domains/__fixtures__/raidlog.html +1 -0
- package/src/domains/__fixtures__/socp.html +1 -0
- package/src/index.ts +10 -4
- package/src/stats.ts +31 -0
- package/src/utils/kmail.ts +3 -3
- package/src/utils/utils.ts +43 -0
- package/src/Cache.ts +0 -33
- package/src/utils/leaderboard.ts +0 -78
- /package/src/{utils → domains}/__fixtures__/leaderboard_wotsf.html +0 -0
- /package/src/{__fixtures__ → domains/__fixtures__}/raffle.html +0 -0
package/src/Player.test.ts
CHANGED
|
@@ -17,104 +17,105 @@ vi.mock("./Client.js", async (importOriginal) => {
|
|
|
17
17
|
|
|
18
18
|
const client = new Client("", "");
|
|
19
19
|
|
|
20
|
-
describe("Player
|
|
21
|
-
test("Can
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
expect(player.name).toBe("Mad Carew");
|
|
47
|
+
expectNotNull(result);
|
|
48
|
+
expect(result.avatar).toContain("<svg");
|
|
33
49
|
});
|
|
34
50
|
|
|
35
|
-
test("Can
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
55
|
+
expectNotNull(result);
|
|
56
|
+
expect(result.avatar).toContain("<svg");
|
|
57
|
+
});
|
|
41
58
|
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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("
|
|
54
|
-
test("
|
|
55
|
-
|
|
56
|
-
|
|
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("
|
|
72
|
-
|
|
73
|
-
|
|
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("
|
|
89
|
-
|
|
90
|
-
|
|
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("
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
id
|
|
67
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|