kol.js 0.0.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.
@@ -0,0 +1,11 @@
1
+ import { Client } from "./Client.js";
2
+ import { Player } from "./Player.js";
3
+ export declare class Cache<T> {
4
+ client: Client;
5
+ constructor(client: Client);
6
+ cache: T[];
7
+ }
8
+ export declare class PlayerCache extends Cache<Player<boolean>> {
9
+ fetch(identifier: string | number, full: true): Promise<Player<true>>;
10
+ fetch(identifier: string | number): Promise<Player<boolean>>;
11
+ }
package/dist/Cache.js ADDED
@@ -0,0 +1,26 @@
1
+ import { Player } from "./Player.js";
2
+ export class Cache {
3
+ client;
4
+ constructor(client) {
5
+ this.client = client;
6
+ }
7
+ cache = [];
8
+ }
9
+ export class PlayerCache extends Cache {
10
+ async fetch(identifier, full = false) {
11
+ const player = await (async () => {
12
+ const cached = this.cache.find(p => p.matchesIdentifier(identifier));
13
+ if (cached) {
14
+ return cached;
15
+ }
16
+ const fetched = await Player.from(this.client, identifier);
17
+ if (!fetched)
18
+ return null;
19
+ this.cache.push(fetched);
20
+ return fetched;
21
+ })();
22
+ if (!player)
23
+ return player;
24
+ return full ? await player.full() : player;
25
+ }
26
+ }
@@ -0,0 +1,78 @@
1
+ import { Mutex } from "async-mutex";
2
+ import TypedEventEmitter, { EventMap } from "typed-emitter";
3
+ import { KoLMessage } from "./utils/kmail.js";
4
+ import { PlayerCache } from "./Cache.js";
5
+ type TypedEmitter<T extends EventMap> = TypedEventEmitter.default<T>;
6
+ type FetchOptions<Result> = {
7
+ params?: Record<string, string | number>;
8
+ body?: Record<string, string | number>;
9
+ method?: "POST" | "GET";
10
+ fallback?: Result;
11
+ };
12
+ type MallPrice = {
13
+ formattedMallPrice: string;
14
+ formattedLimitedMallPrice: string;
15
+ formattedMinPrice: string;
16
+ mallPrice: number;
17
+ limitedMallPrice: number;
18
+ minPrice: number | null;
19
+ };
20
+ type Events = {
21
+ kmail: (message: KoLMessage) => void;
22
+ whisper: (message: KoLMessage) => void;
23
+ system: (message: KoLMessage) => void;
24
+ public: (message: KoLMessage) => void;
25
+ rollover: () => void;
26
+ };
27
+ declare const Client_base: new () => TypedEmitter<Events>;
28
+ export declare class Client extends Client_base {
29
+ #private;
30
+ static loginMutex: Mutex;
31
+ actionMutex: Mutex;
32
+ session: import("fetch-cookie").FetchCookieImpl<string | URL | Request, RequestInit, Response>;
33
+ players: PlayerCache;
34
+ private lastFetchedMessages;
35
+ private postRolloverLatch;
36
+ constructor(username: string, password: string);
37
+ fetchText(path: string, options?: FetchOptions<string>): Promise<string>;
38
+ fetchJson<Result>(path: string, options?: FetchOptions<Result>): Promise<Result | null>;
39
+ login(): Promise<boolean>;
40
+ isRollover(): boolean;
41
+ checkLoggedIn(): Promise<boolean>;
42
+ startChatBot(): Promise<void>;
43
+ private loopChatBot;
44
+ checkMessages(): Promise<void>;
45
+ checkKmails(): Promise<void>;
46
+ sendChat(message: string): Promise<{
47
+ output: string;
48
+ msgs: string[];
49
+ } | null>;
50
+ useChatMacro(macro: string): Promise<{
51
+ output: string;
52
+ msgs: string[];
53
+ } | null>;
54
+ whisper(recipientId: number, message: string): Promise<void>;
55
+ kmail(recipientId: number, message: string): Promise<void>;
56
+ getMallPrice(itemId: number): Promise<MallPrice>;
57
+ getItemDescription(descId: number): Promise<{
58
+ melting: boolean;
59
+ singleEquip: boolean;
60
+ blueText: string;
61
+ effect?: {
62
+ name: string;
63
+ duration: number;
64
+ descid: string;
65
+ };
66
+ }>;
67
+ getEffectDescription(descId: string): Promise<{
68
+ blueText: string;
69
+ }>;
70
+ getSkillDescription(id: number): Promise<{
71
+ blueText: string;
72
+ }>;
73
+ joinClan(id: number): Promise<boolean>;
74
+ addToWhitelist(playerId: number, clanId: number): Promise<boolean>;
75
+ getLeaderboard(leaderboardId: number): Promise<import("./utils/leaderboard.js").LeaderboardInfo>;
76
+ useFamiliar(familiarId: number): Promise<void>;
77
+ }
78
+ export {};
package/dist/Client.js ADDED
@@ -0,0 +1,325 @@
1
+ import { Mutex } from "async-mutex";
2
+ import { EventEmitter } from "node:events";
3
+ import { sanitiseBlueText, wait } from "./utils/utils.js";
4
+ import { Player } from "./Player.js";
5
+ import { createBody, createSession, formatQuerystring } from "./utils/visit.js";
6
+ import { parseLeaderboard } from "./utils/leaderboard.js";
7
+ import { isValidMessage, } from "./utils/kmail.js";
8
+ import { PlayerCache } from "./Cache.js";
9
+ export class Client extends EventEmitter {
10
+ static loginMutex = new Mutex();
11
+ actionMutex = new Mutex();
12
+ session = createSession();
13
+ players = new PlayerCache(this);
14
+ #username;
15
+ #password;
16
+ #isRollover = false;
17
+ #chatBotStarted = false;
18
+ #pwd = "";
19
+ lastFetchedMessages = "0";
20
+ postRolloverLatch = false;
21
+ constructor(username, password) {
22
+ super();
23
+ this.#username = username;
24
+ this.#password = password;
25
+ }
26
+ async #fetch(path, options = {}) {
27
+ const { params, body, method } = {
28
+ params: {},
29
+ body: undefined,
30
+ method: "POST",
31
+ ...options,
32
+ };
33
+ const qs = formatQuerystring(params);
34
+ return await this.session(`https://www.kingdomofloathing.com/${path}${qs ? `?${qs}` : ""}`, {
35
+ method,
36
+ body: body ? createBody({ ...body, pwd: this.#pwd }) : undefined,
37
+ });
38
+ }
39
+ async fetchText(path, options = {}) {
40
+ if (!await this.login())
41
+ return options.fallback ?? "";
42
+ return (await this.#fetch(path, options)).text();
43
+ }
44
+ async fetchJson(path, options = {}) {
45
+ if (!await this.login())
46
+ return options.fallback ?? null;
47
+ return (await this.#fetch(path, options)).json();
48
+ }
49
+ async login() {
50
+ if (this.#isRollover)
51
+ return false;
52
+ return Client.loginMutex.runExclusive(async () => {
53
+ if (await this.checkLoggedIn())
54
+ return true;
55
+ if (this.#isRollover)
56
+ return false;
57
+ try {
58
+ const response = await this.#fetch("login.php", {
59
+ body: {
60
+ loggingin: "Yup.",
61
+ loginname: this.#username,
62
+ password: this.#password,
63
+ secure: "0",
64
+ submitbutton: "Log In",
65
+ },
66
+ });
67
+ await response.text();
68
+ if (!(await this.checkLoggedIn()))
69
+ return false;
70
+ if (this.postRolloverLatch) {
71
+ this.postRolloverLatch = false;
72
+ this.emit("rollover");
73
+ }
74
+ return true;
75
+ }
76
+ catch (error) {
77
+ console.log("error", error);
78
+ // Login failed, let's check if it is due to rollover
79
+ await this.#checkForRollover();
80
+ return false;
81
+ }
82
+ });
83
+ }
84
+ isRollover() {
85
+ return this.#isRollover;
86
+ }
87
+ async checkLoggedIn() {
88
+ try {
89
+ const response = await this.#fetch("api.php", {
90
+ params: { what: "status", for: `${this.#username} bot` },
91
+ });
92
+ const api = (await response.json());
93
+ this.#pwd = api.pwd;
94
+ return true;
95
+ }
96
+ catch (error) {
97
+ return false;
98
+ }
99
+ }
100
+ async #checkForRollover() {
101
+ const isRollover = /The system is currently down for nightly maintenance/.test(await this.fetchText("/"));
102
+ if (this.#isRollover && !isRollover) {
103
+ // Set the post-rollover latch so the bot can react on next log in.
104
+ this.postRolloverLatch = true;
105
+ }
106
+ this.#isRollover = isRollover;
107
+ if (this.#isRollover) {
108
+ // Rollover appears to be in progress. Check again in one minute.
109
+ setTimeout(() => this.#checkForRollover(), 60000);
110
+ }
111
+ }
112
+ async startChatBot() {
113
+ if (this.#chatBotStarted)
114
+ return;
115
+ await this.useChatMacro("/join talkie");
116
+ this.loopChatBot();
117
+ this.#chatBotStarted = true;
118
+ }
119
+ async loopChatBot() {
120
+ await Promise.all([this.checkMessages(), this.checkKmails()]);
121
+ await wait(3000);
122
+ await this.loopChatBot();
123
+ }
124
+ async checkMessages() {
125
+ const newChatMessagesResponse = await this.fetchJson("newchatmessages.php", {
126
+ params: {
127
+ j: 1,
128
+ lasttime: this.lastFetchedMessages,
129
+ },
130
+ });
131
+ if (!newChatMessagesResponse || typeof newChatMessagesResponse !== "object")
132
+ return;
133
+ this.lastFetchedMessages = newChatMessagesResponse["last"];
134
+ newChatMessagesResponse["msgs"]
135
+ .filter(isValidMessage)
136
+ .map((msg) => ({
137
+ type: msg.type,
138
+ who: new Player(this, Number(msg.who.id), msg.who.name),
139
+ msg: msg.msg,
140
+ time: new Date(Number(msg.time) * 1000),
141
+ }))
142
+ .forEach((message) => {
143
+ switch (message.type) {
144
+ case "public":
145
+ return void this.emit("public", message);
146
+ case "private":
147
+ return void this.emit("whisper", message);
148
+ case "system":
149
+ return void this.emit("system", message);
150
+ }
151
+ });
152
+ }
153
+ async checkKmails() {
154
+ const newKmailsResponse = await this.fetchJson("api.php", {
155
+ params: {
156
+ what: "kmail",
157
+ for: `${this.#username} bot`,
158
+ },
159
+ });
160
+ if (!Array.isArray(newKmailsResponse) || newKmailsResponse.length === 0)
161
+ return;
162
+ const newKmails = newKmailsResponse.map((msg) => ({
163
+ type: "kmail",
164
+ who: new Player(this, Number(msg.fromid), msg.fromname),
165
+ msg: msg.message,
166
+ time: new Date(Number(msg.azunixtime) * 1000),
167
+ }));
168
+ const data = {
169
+ the_action: "delete",
170
+ box: "Inbox",
171
+ ...Object.fromEntries(newKmailsResponse.map(({ id }) => [`sel${id}`, "on"])),
172
+ };
173
+ await this.fetchText("messages.php", { params: data });
174
+ newKmails.forEach((m) => this.emit("kmail", m));
175
+ }
176
+ async sendChat(message) {
177
+ return await this.fetchJson("submitnewchat.php", {
178
+ params: {
179
+ graf: message,
180
+ j: 1,
181
+ },
182
+ });
183
+ }
184
+ async useChatMacro(macro) {
185
+ return await this.sendChat(`/clan ${macro}`);
186
+ }
187
+ async whisper(recipientId, message) {
188
+ await this.useChatMacro(`/w ${recipientId} ${message}`);
189
+ }
190
+ async kmail(recipientId, message) {
191
+ await this.fetchText("sendmessage.php", {
192
+ params: {
193
+ action: "send",
194
+ j: 1,
195
+ towho: recipientId,
196
+ contact: 0,
197
+ message: message,
198
+ howmany1: 1,
199
+ whichitem1: 0,
200
+ sendmeat: 0,
201
+ },
202
+ });
203
+ }
204
+ async getMallPrice(itemId) {
205
+ const prices = await this.fetchText("backoffice.php", {
206
+ params: {
207
+ action: "prices",
208
+ ajax: 1,
209
+ iid: itemId,
210
+ },
211
+ });
212
+ const unlimitedMatch = prices.match(/<td>unlimited:<\/td><td><b>(?<unlimitedPrice>[\d,]+)/);
213
+ const limitedMatch = prices.match(/<td>limited:<\/td><td><b>(?<limitedPrice>[\d,]+)/);
214
+ const unlimitedPrice = unlimitedMatch
215
+ ? parseInt(unlimitedMatch[1].replace(/,/g, ""))
216
+ : 0;
217
+ const limitedPrice = limitedMatch
218
+ ? parseInt(limitedMatch[1].replace(/,/g, ""))
219
+ : 0;
220
+ let minPrice = limitedMatch ? limitedPrice : null;
221
+ minPrice = unlimitedMatch
222
+ ? !minPrice || unlimitedPrice < minPrice
223
+ ? unlimitedPrice
224
+ : minPrice
225
+ : minPrice;
226
+ const formattedMinPrice = minPrice
227
+ ? (minPrice === unlimitedPrice
228
+ ? unlimitedMatch?.[1]
229
+ : limitedMatch?.[1]) ?? ""
230
+ : "";
231
+ return {
232
+ mallPrice: unlimitedPrice,
233
+ limitedMallPrice: limitedPrice,
234
+ formattedMinPrice: formattedMinPrice,
235
+ minPrice: minPrice,
236
+ formattedMallPrice: unlimitedMatch ? unlimitedMatch[1] : "",
237
+ formattedLimitedMallPrice: limitedMatch ? limitedMatch[1] : "",
238
+ };
239
+ }
240
+ async getItemDescription(descId) {
241
+ const description = await this.fetchText("desc_item.php", {
242
+ params: {
243
+ whichitem: descId,
244
+ },
245
+ });
246
+ const blueText = description.match(/<center>\s*<b>\s*<font color="?[\w]+"?>(?<description>[\s\S]+)<\/center>/i);
247
+ const effect = description.match(/Effect: \s?<b>\s?<a[^>]+href="desc_effect\.php\?whicheffect=(?<descid>[^"]+)[^>]+>(?<effect>[\s\S]+)<\/a>[^(]+\((?<duration>[\d]+)/);
248
+ const melting = description.match(/This item will disappear at the end of the day\./);
249
+ const singleEquip = description.match(/ You may not equip more than one of these at a time\./);
250
+ return {
251
+ melting: !!melting,
252
+ singleEquip: !!singleEquip,
253
+ blueText: sanitiseBlueText(blueText?.groups?.description),
254
+ effect: effect?.groups
255
+ ? {
256
+ name: effect.groups?.name,
257
+ duration: Number(effect.groups?.duration) || 0,
258
+ descid: effect.groups?.descid,
259
+ }
260
+ : undefined,
261
+ };
262
+ }
263
+ async getEffectDescription(descId) {
264
+ const description = await this.fetchText("desc_effect.php", {
265
+ params: {
266
+ whicheffect: descId,
267
+ },
268
+ });
269
+ const blueText = description.match(/<center><font color="?[\w]+"?>(?<description>[\s\S]+)<\/div>/m);
270
+ return { blueText: sanitiseBlueText(blueText?.groups?.description) };
271
+ }
272
+ async getSkillDescription(id) {
273
+ const description = await this.fetchText("desc_skill.php", {
274
+ params: {
275
+ whichskill: String(id),
276
+ },
277
+ });
278
+ const blueText = description.match(/<blockquote[\s\S]+<[Cc]enter>(?<description>[\s\S]+)<\/[Cc]enter>/);
279
+ return { blueText: sanitiseBlueText(blueText?.groups?.description) };
280
+ }
281
+ async joinClan(id) {
282
+ const result = await this.fetchText("showclan.php", {
283
+ params: {
284
+ whichclan: id,
285
+ action: "joinclan",
286
+ confirm: "on",
287
+ },
288
+ });
289
+ return (result.includes("clanhalltop.gif") ||
290
+ result.includes("a clan you're already in"));
291
+ }
292
+ async addToWhitelist(playerId, clanId) {
293
+ return await this.actionMutex.runExclusive(async () => {
294
+ if (!(await this.joinClan(clanId)))
295
+ return false;
296
+ await this.fetchText("clan_whitelist.php", {
297
+ params: {
298
+ addwho: playerId,
299
+ level: 2,
300
+ title: "",
301
+ action: "add",
302
+ },
303
+ });
304
+ return true;
305
+ });
306
+ }
307
+ async getLeaderboard(leaderboardId) {
308
+ const page = await this.fetchText("museum.php", {
309
+ params: {
310
+ floor: 1,
311
+ place: "leaderboards",
312
+ whichboard: leaderboardId,
313
+ },
314
+ });
315
+ return parseLeaderboard(page);
316
+ }
317
+ async useFamiliar(familiarId) {
318
+ await this.fetchText("familiar.php", {
319
+ params: {
320
+ action: "newfam",
321
+ newfam: familiarId.toFixed(0),
322
+ },
323
+ });
324
+ }
325
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { Client } from "./Client.js";
3
+ import { loadFixture } from "./testUtils.js";
4
+ const { json, text } = vi.hoisted(() => ({ json: vi.fn(), text: vi.fn() }));
5
+ vi.mock("./utils/visit.js", () => ({
6
+ createSession: vi.fn().mockReturnValue({ json, text }),
7
+ }));
8
+ const client = new Client("", "");
9
+ describe("LoathingChat", () => {
10
+ test("Can parse a regular message", async () => {
11
+ json.mockResolvedValue({});
12
+ json.mockResolvedValueOnce({
13
+ msgs: [
14
+ {
15
+ msg: "testing",
16
+ type: "public",
17
+ mid: "1538072797",
18
+ who: { name: "gAUSIE", id: "1197090", color: "black" },
19
+ format: "0",
20
+ channel: "talkie",
21
+ channelcolor: "green",
22
+ time: "1698787642",
23
+ },
24
+ ],
25
+ });
26
+ const messageSpy = vi.fn();
27
+ client.on("public", messageSpy);
28
+ await client.checkMessages();
29
+ expect(messageSpy).toHaveBeenCalledOnce();
30
+ const [message] = messageSpy.mock.calls[0];
31
+ expect(message).toMatchObject({
32
+ type: "public",
33
+ msg: "testing",
34
+ time: new Date(1698787642000),
35
+ who: { id: 1197090, name: "gAUSIE" },
36
+ });
37
+ });
38
+ test("Can parse a system message for rollover in 5 minutes", async () => {
39
+ json.mockResolvedValueOnce({
40
+ msgs: [
41
+ {
42
+ msg: "The system will go down for nightly maintenance in 5 minutes.",
43
+ type: "system",
44
+ mid: "1538084998",
45
+ who: { name: "System Message", id: "-1", color: "" },
46
+ format: "2",
47
+ channelcolor: "green",
48
+ time: "1698809101",
49
+ },
50
+ ],
51
+ });
52
+ const messageSpy = vi.fn();
53
+ client.on("system", messageSpy);
54
+ await client.checkMessages();
55
+ expect(messageSpy).toHaveBeenCalledOnce();
56
+ const [message] = messageSpy.mock.calls[0];
57
+ expect(message).toMatchObject({
58
+ type: "system",
59
+ who: { id: -1, name: "System Message" },
60
+ msg: "The system will go down for nightly maintenance in 5 minutes.",
61
+ time: new Date(1698809101000),
62
+ });
63
+ });
64
+ test("Can parse a system message for rollover in one minute", async () => {
65
+ json.mockResolvedValueOnce({
66
+ msgs: [
67
+ {
68
+ msg: "The system will go down for nightly maintenance in 1 minute.",
69
+ type: "system",
70
+ mid: "1538084998",
71
+ who: { name: "System Message", id: "-1", color: "" },
72
+ format: "2",
73
+ channelcolor: "green",
74
+ time: "1698809101",
75
+ },
76
+ ],
77
+ });
78
+ const messageSpy = vi.fn();
79
+ client.on("system", messageSpy);
80
+ await client.checkMessages();
81
+ expect(messageSpy).toHaveBeenCalledOnce();
82
+ const [message] = messageSpy.mock.calls[0];
83
+ expect(message).toMatchObject({
84
+ type: "system",
85
+ who: { id: -1, name: "System Message" },
86
+ msg: "The system will go down for nightly maintenance in 1 minute.",
87
+ time: new Date(1698809101000),
88
+ });
89
+ });
90
+ test("Can parse a system message for rollover complete", async () => {
91
+ json.mockResolvedValueOnce({
92
+ msgs: [
93
+ {
94
+ msg: "Rollover is over.",
95
+ type: "system",
96
+ mid: "1538085619",
97
+ who: { name: "System Message", id: "-1", color: "" },
98
+ format: "2",
99
+ channelcolor: "green",
100
+ time: "1698809633",
101
+ },
102
+ ],
103
+ });
104
+ const messageSpy = vi.fn();
105
+ client.on("system", messageSpy);
106
+ await client.checkMessages();
107
+ expect(messageSpy).toHaveBeenCalledOnce();
108
+ const [message] = messageSpy.mock.calls[0];
109
+ expect(message).toMatchObject({
110
+ type: "system",
111
+ who: { id: -1, name: "System Message" },
112
+ msg: "Rollover is over.",
113
+ time: new Date(1698809633000),
114
+ });
115
+ });
116
+ });
117
+ describe("Skill descriptions", () => {
118
+ test("can describe a Skill with no bluetext", async () => {
119
+ text.mockResolvedValueOnce(await loadFixture(__dirname, "desc_skill_overload_discarded_refridgerator.html"));
120
+ const description = await client.getSkillDescription(7017);
121
+ expect(description).toStrictEqual({
122
+ blueText: "",
123
+ });
124
+ });
125
+ test("can describe a Skill with bluetext", async () => {
126
+ text.mockResolvedValueOnce(await loadFixture(__dirname, "desc_skill_impetuous_sauciness.html"));
127
+ const description = await client.getSkillDescription(4015);
128
+ expect(description).toStrictEqual({
129
+ blueText: "Makes Sauce Potions last longer",
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,28 @@
1
+ import { Client } from "./Client.js";
2
+ export type If<Value extends boolean, TrueResult, FalseResult = null> = Value extends true ? TrueResult : Value extends false ? FalseResult : TrueResult | FalseResult;
3
+ export declare class Player<IsFull extends boolean = boolean> {
4
+ #private;
5
+ id: number;
6
+ name: string;
7
+ level: If<IsFull, number>;
8
+ kolClass: If<IsFull, string>;
9
+ avatar: If<IsFull, string>;
10
+ ascensions: If<IsFull, number>;
11
+ trophies: If<IsFull, number>;
12
+ tattoos: If<IsFull, number>;
13
+ favoriteFood: If<IsFull, string | null>;
14
+ favoriteBooze: If<IsFull, string | null>;
15
+ createdDate: If<IsFull, Date>;
16
+ lastLogin: If<IsFull, Date>;
17
+ hasDisplayCase: If<IsFull, boolean>;
18
+ constructor(client: Client, id: number, name: string, level?: number | null, kolClass?: string | null);
19
+ isFull(): this is Player<true>;
20
+ isPartial(): this is Player<false>;
21
+ static getNameFromId(client: Client, id: number): Promise<string | null>;
22
+ static fromName(client: Client, name: string): Promise<Player<false> | null>;
23
+ static fromId(client: Client, id: number): Promise<Player<false> | null>;
24
+ static from(client: Client, identifier: string | number): Promise<Player<false> | null>;
25
+ matchesIdentifier(identifier: string | number): boolean;
26
+ full(): Promise<Player<true> | null>;
27
+ isOnline(): Promise<boolean>;
28
+ }
package/dist/Player.js ADDED
@@ -0,0 +1,136 @@
1
+ import { generateAvatarSvg } from "./utils/avatar.js";
2
+ import { parsePlayerDate } from "./utils/utils.js";
3
+ export class Player {
4
+ #client;
5
+ id;
6
+ name;
7
+ level;
8
+ kolClass;
9
+ avatar;
10
+ ascensions;
11
+ trophies;
12
+ tattoos;
13
+ favoriteFood;
14
+ favoriteBooze;
15
+ createdDate;
16
+ lastLogin;
17
+ hasDisplayCase;
18
+ constructor(client, id, name, level = null, kolClass = null) {
19
+ this.#client = client;
20
+ this.id = id;
21
+ this.name = name;
22
+ this.level = level;
23
+ this.kolClass = kolClass;
24
+ this.avatar = null;
25
+ this.ascensions = null;
26
+ this.trophies = null;
27
+ this.tattoos = null;
28
+ this.favoriteFood = null;
29
+ this.favoriteBooze = null;
30
+ this.createdDate = null;
31
+ this.lastLogin = null;
32
+ this.hasDisplayCase = null;
33
+ }
34
+ isFull() {
35
+ return this.createdDate !== null;
36
+ }
37
+ isPartial() {
38
+ return this.createdDate === null;
39
+ }
40
+ static async getNameFromId(client, id) {
41
+ try {
42
+ const profile = await client.fetchText("showplayer.php", {
43
+ params: { who: id },
44
+ });
45
+ const name = profile.match(/<b>([^>]*?)<\/b> \(#(\d+)\)<br>/)?.[1];
46
+ return name || null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ static async fromName(client, name) {
53
+ try {
54
+ const matcher = /href="showplayer.php\?who=(?<playerId>\d+)">(?<playerName>.*?)<\/a>\D+(clan=\d+[^<]+\D+)?\d+\D*(?<level>(\d+)|(inf_large\.gif))\D+valign=top>(?<class>[^<]*)<\/td>/i;
55
+ const search = await client.fetchText("searchplayer.php", {
56
+ params: {
57
+ searchstring: name.replace(/_/g, "\\_"),
58
+ searching: "Yep.",
59
+ for: "",
60
+ startswith: 1,
61
+ hardcoreonly: 0,
62
+ },
63
+ });
64
+ const match = matcher.exec(search)?.groups;
65
+ if (!match) {
66
+ return null;
67
+ }
68
+ return new Player(client, Number(match.playerId), match.playerName, parseInt(match.level), match.class);
69
+ }
70
+ catch (error) {
71
+ return null;
72
+ }
73
+ }
74
+ static async fromId(client, id) {
75
+ const name = await Player.getNameFromId(client, id);
76
+ if (!name)
77
+ return null;
78
+ return await Player.fromName(client, name);
79
+ }
80
+ static async from(client, identifier) {
81
+ const id = Number(identifier);
82
+ if (!Number.isNaN(id) || typeof identifier === "number") {
83
+ return await Player.fromId(client, id);
84
+ }
85
+ return await Player.fromName(client, identifier);
86
+ }
87
+ matchesIdentifier(identifier) {
88
+ const id = Number(identifier);
89
+ if (!Number.isNaN(id) || typeof identifier === "number") {
90
+ return this.id === identifier;
91
+ }
92
+ return this.name.toLowerCase() === identifier.toLowerCase();
93
+ }
94
+ async full() {
95
+ const t = this;
96
+ if (this.isFull())
97
+ return this;
98
+ try {
99
+ const profile = await this.#client.fetchText("showplayer.php", {
100
+ params: {
101
+ who: this.id,
102
+ },
103
+ });
104
+ const header = profile.match(/<center><table><tr><td><center>.*?<img.*?src="(.*?)".*?<b>([^>]*?)<\/b> \(#(\d+)\)<br>/);
105
+ if (!header)
106
+ return null;
107
+ t.avatar = (await generateAvatarSvg(profile)) || header[1];
108
+ t.ascensions =
109
+ Number(profile
110
+ .match(/>Ascensions<\/a>:<\/b><\/td><td>(.*?)<\/td>/)?.[1]
111
+ ?.replace(/,/g, "")) || 0;
112
+ t.trophies = Number(profile.match(/>Trophies Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
113
+ 0);
114
+ t.tattoos = Number(profile.match(/>Tattoos Collected:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
115
+ 0);
116
+ t.favoriteFood =
117
+ profile.match(/>Favorite Food:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ?? null;
118
+ t.favoriteBooze =
119
+ profile.match(/>Favorite Booze:<\/b><\/td><td>(.*?)<\/td>/)?.[1] ??
120
+ null;
121
+ t.createdDate = parsePlayerDate(profile.match(/>Account Created:<\/b><\/td><td>(.*?)<\/td>/)?.[1]);
122
+ t.lastLogin = parsePlayerDate(profile.match(/>Last Login:<\/b><\/td><td>(.*?)<\/td>/)?.[1]);
123
+ t.hasDisplayCase =
124
+ profile.match(/Display Case<\/b><\/a> in the Museum<\/td>/) !== null;
125
+ return t;
126
+ }
127
+ catch (error) {
128
+ console.error(error);
129
+ return null;
130
+ }
131
+ }
132
+ async isOnline() {
133
+ const response = await this.#client.useChatMacro(`/whois ${this.name}`);
134
+ return response?.output.includes("This player is currently online") ?? false;
135
+ }
136
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
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
+ const { json, text } = vi.hoisted(() => ({ json: vi.fn(), text: vi.fn() }));
7
+ vi.mock("./utils/visit.js", () => ({
8
+ createSession: vi.fn().mockReturnValue({ json, text }),
9
+ }));
10
+ const client = new Client("", "");
11
+ test("Can search for a player by name", async () => {
12
+ vi.mocked(text).mockResolvedValueOnce(await loadFixture(__dirname, "searchplayer_mad_carew.html"));
13
+ const player = await Player.fromName(client, "mad carew");
14
+ expectNotNull(player);
15
+ expect(player.id).toBe(263717);
16
+ // Learns correct capitalisation
17
+ expect(player.name).toBe("Mad Carew");
18
+ });
19
+ describe("Profile parsing", () => {
20
+ test("Can parse a profile picture", async () => {
21
+ vi.mocked(text).mockResolvedValueOnce(await loadFixture(__dirname, "showplayer_regular.html"));
22
+ const player = await new Player(client, 2264486, "SSBBHax", 1, "Sauceror").full();
23
+ expectNotNull(player);
24
+ expect(player.avatar).toContain("<svg");
25
+ });
26
+ test("Can parse a profile picture on dependence day", async () => {
27
+ vi.mocked(text).mockResolvedValueOnce(await loadFixture(__dirname, "showplayer_dependence_day.html"));
28
+ const player = await new Player(client, 3019702, "Name Guy Man", 1, "Sauceror").full();
29
+ expectNotNull(player);
30
+ expect(player.avatar).toContain("<svg");
31
+ });
32
+ test("Can parse an avatar when the player has been painted gold", async () => {
33
+ vi.mocked(text).mockResolvedValueOnce(await loadFixture(__dirname, "showplayer_golden_gun.html"));
34
+ const player = await new Player(client, 1197090, "gAUSIE", 15, "Sauceror").full();
35
+ expectNotNull(player);
36
+ expect(player.avatar).toContain("<svg");
37
+ });
38
+ test("Can resolve KoL images", () => {
39
+ expect(resolveKoLImage("/iii/otherimages/classav31_f.gif")).toBe("https://s3.amazonaws.com/images.kingdomofloathing.com/otherimages/classav31_f.gif");
40
+ expect(resolveKoLImage("/itemimages/oaf.gif")).toBe("https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif");
41
+ expect(resolveKoLImage("https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif")).toBe("https://s3.amazonaws.com/images.kingdomofloathing.com/itemimages/oaf.gif");
42
+ });
43
+ });
@@ -0,0 +1,6 @@
1
+ export { Player } from "./Player.js";
2
+ export { Client } from "./Client.js";
3
+ export { createSession } from "./utils/visit.js";
4
+ export { resolveKoLImage } from "./utils/utils.js";
5
+ export type { KoLMessage } from "./utils/kmail.js";
6
+ export type { SubboardInfo } from "./utils/leaderboard.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { Player } from "./Player.js";
2
+ export { Client } from "./Client.js";
3
+ export { createSession } from "./utils/visit.js";
4
+ export { resolveKoLImage } from "./utils/utils.js";
@@ -0,0 +1 @@
1
+ export {};
package/dist/start.js ADDED
@@ -0,0 +1,4 @@
1
+ import { Client } from "./Client.js";
2
+ const client = new Client("onweb", "beer146beef31");
3
+ const gausie = await client.players.fetch("gausie", true);
4
+ console.log(gausie);
@@ -0,0 +1,2 @@
1
+ export declare function loadFixture(dirname: string, name: string): Promise<string>;
2
+ export declare function expectNotNull<T>(value: T | null): asserts value is T;
@@ -0,0 +1,10 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { expect } from "vitest";
4
+ export async function loadFixture(dirname, name) {
5
+ const file = path.join(dirname, `__fixtures__/${name}`);
6
+ return await fs.readFile(file, { encoding: "utf-8" });
7
+ }
8
+ export function expectNotNull(value) {
9
+ expect(value).not.toBeNull();
10
+ }
@@ -0,0 +1 @@
1
+ export declare function generateAvatarSvg(profile: string): Promise<string | null>;
@@ -0,0 +1,70 @@
1
+ import { parse } from "node-html-parser";
2
+ import { resolveKoLImage } from "./utils.js";
3
+ import { imageSize } from "image-size";
4
+ import { dedent } from "ts-dedent";
5
+ export async function generateAvatarSvg(profile) {
6
+ const header = profile.match(/<center><table><tr><td><center>.*?(<div.*?>.*?<\/div>).*?<b>([^>]*?)<\/b> \(#(\d+)\)<br>/);
7
+ const blockHtml = header?.[1];
8
+ if (!blockHtml)
9
+ return null;
10
+ const block = parse(blockHtml).querySelector("div");
11
+ if (!block)
12
+ return null;
13
+ const ocrsColour = ["gold", "red"].find((k) => block.classList.contains(k)) ?? "black";
14
+ const images = [];
15
+ for (const imgElement of block.querySelectorAll("img")) {
16
+ const src = imgElement.getAttribute("src");
17
+ if (!src)
18
+ continue;
19
+ const result = await fetch(resolveKoLImage(src));
20
+ const buffer = Buffer.from(await result.arrayBuffer());
21
+ const { width = 0, height = 0 } = imageSize(buffer);
22
+ const href = `data:image/png;base64,${buffer.toString("base64")}`;
23
+ const style = imgElement.getAttribute("style");
24
+ const top = Number(style?.match(/top: ?(-?\d+)px/i)?.[1] || "0");
25
+ const left = Number(style?.match(/left: ?(-?\d+)px/i)?.[1] || "0");
26
+ const rotate = Number(style?.match(/rotate\((-?\d+)deg\)/)?.[1] || "0");
27
+ images.push({
28
+ href,
29
+ top,
30
+ left,
31
+ rotate,
32
+ width,
33
+ height,
34
+ });
35
+ }
36
+ const width = Math.max(...images.map((i) => i.left + i.width));
37
+ return dedent `
38
+ <svg width="${width}" height="100" xmlns="http://www.w3.org/2000/svg">
39
+ <defs>
40
+ <filter id="colorMask">
41
+ <feComponentTransfer in="SourceGraphic" out="f1">
42
+ <feFuncR type="discrete" tableValues="1 0"/>
43
+ <feFuncG type="discrete" tableValues="1 0"/>
44
+ <feFuncB type="discrete" tableValues="1 0"/>
45
+ </feComponentTransfer>
46
+ <feColorMatrix type="matrix" values="1 0 0 0 0
47
+ 0 1 0 0 0
48
+ 0 0 1 0 0
49
+ 1 1 1 1 -3" result="selectedColor"/>
50
+ <feFlood flood-color="${ocrsColour}"/>
51
+ <feComposite operator="in" in2="selectedColor"/>
52
+ <feComposite operator="over" in2="SourceGraphic"/>
53
+ </filter>
54
+ </defs>
55
+ ${images
56
+ .map((i) => dedent `
57
+ <image
58
+ filter="url(#colorMask)"
59
+ href="${i.href}"
60
+ width="${i.width}"
61
+ height="${i.height}"
62
+ x="${i.left}"
63
+ y="${i.top}"
64
+ transform="rotate(${i.rotate},${i.width / 2 + i.left},${i.height / 2 + i.top})"
65
+ />
66
+ `)
67
+ .join("\n")}
68
+ </svg>
69
+ `;
70
+ }
@@ -0,0 +1,32 @@
1
+ import { Player } from "../Player.js";
2
+ export type KoLChatMessage = {
3
+ who?: Player<false>;
4
+ type?: string;
5
+ msg?: string;
6
+ link?: string;
7
+ channel?: string;
8
+ time: string;
9
+ };
10
+ type KoLMessageType = "private" | "system" | "public" | "kmail";
11
+ export declare const isValidMessage: (msg: KoLChatMessage) => msg is KoLChatMessage & {
12
+ type: KoLMessageType;
13
+ who: Player<false>;
14
+ msg: string;
15
+ };
16
+ export type KoLKmail = {
17
+ id: string;
18
+ type: string;
19
+ fromid: string;
20
+ fromname: string;
21
+ azunixtime: string;
22
+ message: string;
23
+ localtime: string;
24
+ };
25
+ export type KoLMessage = {
26
+ type: KoLMessageType;
27
+ who: Player<false>;
28
+ msg: string;
29
+ time: Date;
30
+ channel?: string;
31
+ };
32
+ export {};
@@ -0,0 +1 @@
1
+ export const isValidMessage = (msg) => msg.who !== undefined && msg.msg !== undefined;
@@ -0,0 +1,16 @@
1
+ export type LeaderboardInfo = {
2
+ name: string;
3
+ boards: SubboardInfo[];
4
+ };
5
+ export type SubboardInfo = {
6
+ name: string;
7
+ runs: RunInfo[];
8
+ updated: Date | null;
9
+ };
10
+ type RunInfo = {
11
+ player: string;
12
+ days: string;
13
+ turns: string;
14
+ };
15
+ export declare function parseLeaderboard(page: string): LeaderboardInfo;
16
+ export {};
@@ -0,0 +1,56 @@
1
+ import xpath, { select } from "xpath";
2
+ import { DOMParser } from "@xmldom/xmldom";
3
+ const parser = new DOMParser({
4
+ locator: {},
5
+ errorHandler: {
6
+ warning: () => { },
7
+ error: () => { },
8
+ fatalError: console.error,
9
+ },
10
+ });
11
+ const selectMulti = (expression, node) => {
12
+ const selection = select(expression, node);
13
+ if (Array.isArray(selection))
14
+ return selection;
15
+ return selection instanceof Node ? [selection] : [];
16
+ };
17
+ export function parseLeaderboard(page) {
18
+ const document = parser.parseFromString(page);
19
+ const [board, ...boards] = selectMulti("//table", document);
20
+ return {
21
+ name: selectMulti(".//text()", board.firstChild)
22
+ .map((node) => node.nodeValue)
23
+ .join("")
24
+ .replace(/\s+/g, " ")
25
+ .trim(),
26
+ boards: boards
27
+ .slice(1)
28
+ .filter((board) => selectMulti("./tr//text()", board)[0]?.nodeValue?.match(/^((Fast|Funn|B)est|Most (Goo|Elf))/) && selectMulti("./tr", board).length > 1)
29
+ .map((subboard) => {
30
+ const rows = selectMulti("./tr", subboard);
31
+ return {
32
+ name: (selectMulti(".//text()", rows[0])[0]?.nodeValue || "").trim(),
33
+ runs: selectMulti("./td//tr", rows[1])
34
+ .slice(2)
35
+ .map((node) => {
36
+ const rowText = selectMulti(".//text()", node).map((text) => text.toString().replace(/&amp;nbsp;/g, ""));
37
+ const hasTwoNumbers = !!parseInt(rowText[rowText.length - 2]);
38
+ return {
39
+ player: rowText
40
+ .slice(0, rowText.length - (hasTwoNumbers ? 2 : 1))
41
+ .join("")
42
+ .trim()
43
+ .toString(),
44
+ days: hasTwoNumbers
45
+ ? rowText[rowText.length - 2].toString() || "0"
46
+ : "",
47
+ turns: rowText[rowText.length - 1].toString() || "0",
48
+ };
49
+ }),
50
+ updated: xpath.isComment(subboard.nextSibling)
51
+ ? new Date(subboard.nextSibling.data.slice(9, -1))
52
+ : null,
53
+ };
54
+ }),
55
+ };
56
+ }
@@ -0,0 +1,4 @@
1
+ export declare function parsePlayerDate(input?: string): Date;
2
+ export declare function sanitiseBlueText(blueText: string | undefined): string;
3
+ export declare function resolveKoLImage(path: string): string;
4
+ export declare function wait(ms: number): Promise<unknown>;
@@ -0,0 +1,27 @@
1
+ import { parse as parseDate } from "date-fns";
2
+ import { decode } from "html-entities";
3
+ export function parsePlayerDate(input) {
4
+ if (!input)
5
+ return new Date();
6
+ return parseDate(input, "MMMM dd, yyyy", new Date());
7
+ }
8
+ export function sanitiseBlueText(blueText) {
9
+ if (!blueText)
10
+ return "";
11
+ return decode(blueText
12
+ .replace(/\r/g, "")
13
+ .replace(/\r/g, "")
14
+ .replace(/(<p><\/p>)|(<br>)|(<Br>)|(<br \/>)|(<Br \/>)/g, "\n")
15
+ .replace(/<[^<>]+>/g, "")
16
+ .replace(/(\n+)/g, "\n")
17
+ .replace(/(\n)+$/, "")).trim();
18
+ }
19
+ export function resolveKoLImage(path) {
20
+ if (!/^https?:\/\//i.test(path))
21
+ return ("https://s3.amazonaws.com/images.kingdomofloathing.com" +
22
+ path.replace(/^\/(iii|images)/, ""));
23
+ return path;
24
+ }
25
+ export function wait(ms) {
26
+ return new Promise((r) => setTimeout(r, ms));
27
+ }
@@ -0,0 +1,3 @@
1
+ export declare function createBody(data: Record<string, string | number>): URLSearchParams;
2
+ export declare function formatQuerystring(params: Record<string, string | number>): URLSearchParams;
3
+ export declare const createSession: () => import("fetch-cookie").FetchCookieImpl<string | URL | Request, RequestInit, Response>;
@@ -0,0 +1,10 @@
1
+ import makeFetchCookie from "fetch-cookie";
2
+ export function createBody(data) {
3
+ const formData = new FormData();
4
+ Object.entries(data).forEach(([key, value]) => formData.append(key, String(value)));
5
+ return new URLSearchParams(formData);
6
+ }
7
+ export function formatQuerystring(params) {
8
+ return new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])));
9
+ }
10
+ export const createSession = () => makeFetchCookie(fetch);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "kol.js",
3
+ "version": "0.0.1",
4
+ "main": "dist/index.js",
5
+ "type": "module",
6
+ "files": [
7
+ "/dist"
8
+ ],
9
+ "license": "MIT",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest",
13
+ "format": "prettier --write ."
14
+ },
15
+ "devDependencies": {
16
+ "prettier": "^3.2.5",
17
+ "typescript": "^5.4.5",
18
+ "vitest": "^1.6.0"
19
+ },
20
+ "dependencies": {
21
+ "@xmldom/xmldom": "^0.8.10",
22
+ "async-mutex": "^0.5.0",
23
+ "date-fns": "^3.6.0",
24
+ "fetch-cookie": "^3.0.1",
25
+ "node-html-parser": "^6.1.13",
26
+ "querystring": "^0.2.1",
27
+ "typed-emitter": "^2.1.0",
28
+ "xpath": "^0.0.34"
29
+ }
30
+ }