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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { Client } from "../Client.js";
|
|
2
|
+
|
|
3
|
+
export class JoinClanError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super("Could not join clan");
|
|
6
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RaidLogMissingError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Raid log missing");
|
|
13
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type RaidLogEvent =
|
|
18
|
+
| {
|
|
19
|
+
type: "kill";
|
|
20
|
+
playerName: string;
|
|
21
|
+
playerId: number;
|
|
22
|
+
monster: string;
|
|
23
|
+
count: number;
|
|
24
|
+
boss: boolean;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
type: "defeat";
|
|
28
|
+
playerName: string;
|
|
29
|
+
playerId: number;
|
|
30
|
+
monster: string;
|
|
31
|
+
count: number;
|
|
32
|
+
boss: boolean;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
type: "loot";
|
|
36
|
+
playerName: string;
|
|
37
|
+
playerId: number;
|
|
38
|
+
item: string;
|
|
39
|
+
recipientName: string;
|
|
40
|
+
recipientId: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const PLAYER_PREFIX = `([A-Za-z0-9\\-_ ]+)\\s+\\(#(\\d+)\\)\\s+`;
|
|
44
|
+
|
|
45
|
+
const KILL_MULTI = new RegExp(
|
|
46
|
+
`^${PLAYER_PREFIX}defeated\\s+(.+?)\\s+x\\s+(\\d+)`,
|
|
47
|
+
"i",
|
|
48
|
+
);
|
|
49
|
+
const KILL_SINGLE = new RegExp(
|
|
50
|
+
`^${PLAYER_PREFIX}defeated\\s+(.+?)\\s+\\(1 turn\\)`,
|
|
51
|
+
"i",
|
|
52
|
+
);
|
|
53
|
+
const DEFEAT_MULTI = new RegExp(
|
|
54
|
+
`^${PLAYER_PREFIX}was defeated by\\s+(.+?)\\s+x\\s+(\\d+)`,
|
|
55
|
+
"i",
|
|
56
|
+
);
|
|
57
|
+
const DEFEAT_SINGLE = new RegExp(
|
|
58
|
+
`^${PLAYER_PREFIX}was defeated by\\s+(.+?)\\s+\\(1 turn\\)`,
|
|
59
|
+
"i",
|
|
60
|
+
);
|
|
61
|
+
const LOOT = new RegExp(
|
|
62
|
+
`^${PLAYER_PREFIX}distributed\\s+(.+?)\\s+to\\s+(.+?)\\s+\\(#(\\d+)\\)`,
|
|
63
|
+
"i",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
function matchKill(line: string, bossNames: string[]): RaidLogEvent | null {
|
|
67
|
+
const multi = line.match(KILL_MULTI);
|
|
68
|
+
if (multi) {
|
|
69
|
+
const monster = multi[3].trim();
|
|
70
|
+
return {
|
|
71
|
+
type: "kill",
|
|
72
|
+
playerName: multi[1].trim(),
|
|
73
|
+
playerId: parseInt(multi[2]),
|
|
74
|
+
monster,
|
|
75
|
+
count: parseInt(multi[4]),
|
|
76
|
+
boss: bossNames.some((b) =>
|
|
77
|
+
monster.toLowerCase().includes(b.toLowerCase()),
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const single = line.match(KILL_SINGLE);
|
|
83
|
+
if (single) {
|
|
84
|
+
const monster = single[3].trim();
|
|
85
|
+
return {
|
|
86
|
+
type: "kill",
|
|
87
|
+
playerName: single[1].trim(),
|
|
88
|
+
playerId: parseInt(single[2]),
|
|
89
|
+
monster,
|
|
90
|
+
count: 1,
|
|
91
|
+
boss: bossNames.some((b) =>
|
|
92
|
+
monster.toLowerCase().includes(b.toLowerCase()),
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function matchDefeat(line: string, bossNames: string[]): RaidLogEvent | null {
|
|
101
|
+
const multi = line.match(DEFEAT_MULTI);
|
|
102
|
+
if (multi) {
|
|
103
|
+
const monster = multi[3].trim();
|
|
104
|
+
return {
|
|
105
|
+
type: "defeat",
|
|
106
|
+
playerName: multi[1].trim(),
|
|
107
|
+
playerId: parseInt(multi[2]),
|
|
108
|
+
monster,
|
|
109
|
+
count: parseInt(multi[4]),
|
|
110
|
+
boss: bossNames.some((b) =>
|
|
111
|
+
monster.toLowerCase().includes(b.toLowerCase()),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const single = line.match(DEFEAT_SINGLE);
|
|
117
|
+
if (single) {
|
|
118
|
+
const monster = single[3].trim();
|
|
119
|
+
return {
|
|
120
|
+
type: "defeat",
|
|
121
|
+
playerName: single[1].trim(),
|
|
122
|
+
playerId: parseInt(single[2]),
|
|
123
|
+
monster,
|
|
124
|
+
count: 1,
|
|
125
|
+
boss: bossNames.some((b) =>
|
|
126
|
+
monster.toLowerCase().includes(b.toLowerCase()),
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matchLoot(line: string): RaidLogEvent | null {
|
|
135
|
+
const match = line.match(LOOT);
|
|
136
|
+
if (!match) return null;
|
|
137
|
+
return {
|
|
138
|
+
type: "loot",
|
|
139
|
+
playerName: match[1].trim(),
|
|
140
|
+
playerId: parseInt(match[2]),
|
|
141
|
+
item: match[3].trim(),
|
|
142
|
+
recipientName: match[4].trim(),
|
|
143
|
+
recipientId: parseInt(match[5]),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Try to parse a single stripped log line into a base raid log event.
|
|
149
|
+
* Returns null if the line doesn't match any known pattern.
|
|
150
|
+
*/
|
|
151
|
+
export function parseLine(
|
|
152
|
+
line: string,
|
|
153
|
+
bossNames: string[],
|
|
154
|
+
): RaidLogEvent | null {
|
|
155
|
+
return (
|
|
156
|
+
matchKill(line, bossNames) ??
|
|
157
|
+
matchDefeat(line, bossNames) ??
|
|
158
|
+
matchLoot(line)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Service class for interacting with KoL clan dungeons.
|
|
164
|
+
* Handles joining clans, fetching raid logs, and creating
|
|
165
|
+
* raid instances from the fetched HTML.
|
|
166
|
+
*/
|
|
167
|
+
export class ClanDungeon {
|
|
168
|
+
#client: Client;
|
|
169
|
+
|
|
170
|
+
constructor(client: Client) {
|
|
171
|
+
this.#client = client;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async getCurrentRaid(clanId: number): Promise<string> {
|
|
175
|
+
return await this.#client.actionMutex.runExclusive(async () => {
|
|
176
|
+
if (!(await this.#client.joinClan(clanId))) throw new JoinClanError();
|
|
177
|
+
const log = await this.#client.fetchText("clan_raidlogs.php");
|
|
178
|
+
if (!log) throw new RaidLogMissingError();
|
|
179
|
+
return log;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getRaidById(clanId: number, raidId: number): Promise<string> {
|
|
184
|
+
return await this.#client.actionMutex.runExclusive(async () => {
|
|
185
|
+
if (!(await this.#client.joinClan(clanId))) throw new JoinClanError();
|
|
186
|
+
return await this.#client.fetchText("clan_viewraidlog.php", {
|
|
187
|
+
query: {
|
|
188
|
+
viewlog: raidId,
|
|
189
|
+
backstart: 0,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async getRaidIds(clanId: number, exclude: number[] = []): Promise<number[]> {
|
|
196
|
+
return await this.#client.actionMutex.runExclusive(async () => {
|
|
197
|
+
if (!(await this.#client.joinClan(clanId))) throw new JoinClanError();
|
|
198
|
+
let raidLogs = await this.#client.fetchText("clan_oldraidlogs.php");
|
|
199
|
+
const raidIds: number[] = [];
|
|
200
|
+
let row = 0;
|
|
201
|
+
let done = false;
|
|
202
|
+
while (
|
|
203
|
+
!raidLogs.includes("No previous Clan Dungeon records found") &&
|
|
204
|
+
!done
|
|
205
|
+
) {
|
|
206
|
+
const matches =
|
|
207
|
+
raidLogs.match(
|
|
208
|
+
/kisses<\/td><td class=tiny>\[<a href="clan_viewraidlog\.php\?viewlog=(?<id>\d+)/g,
|
|
209
|
+
) || [];
|
|
210
|
+
for (const id of matches) {
|
|
211
|
+
const cleanId = Number(id.replace(/\D/g, ""));
|
|
212
|
+
if (exclude.includes(cleanId)) {
|
|
213
|
+
done = true;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
raidIds.push(cleanId);
|
|
217
|
+
}
|
|
218
|
+
if (!done) {
|
|
219
|
+
row += 10;
|
|
220
|
+
raidLogs = await this.#client.fetchText("clan_oldraidlogs.php", {
|
|
221
|
+
query: {
|
|
222
|
+
startrow: row,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return raidIds;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { loadFixture } from "../testUtils.js";
|
|
5
|
+
import { DreadsylvaniaRaid, type DreadEvent } from "./Dreadsylvania.js";
|
|
6
|
+
|
|
7
|
+
function loadDreadFixture(name: string): string {
|
|
8
|
+
return readFileSync(
|
|
9
|
+
join(import.meta.dirname, "__fixtures__", "dread", name),
|
|
10
|
+
"utf8",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function dread(name: string): DreadsylvaniaRaid {
|
|
15
|
+
return new DreadsylvaniaRaid(loadDreadFixture(name));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("overview", () => {
|
|
19
|
+
describe("remaining monsters", () => {
|
|
20
|
+
test("in-progress dungeon", () => {
|
|
21
|
+
const overview = dread("cdr1-current.html").getOverview();
|
|
22
|
+
expect(overview.forest.remaining).toBe(493);
|
|
23
|
+
expect(overview.village.remaining).toBe(509);
|
|
24
|
+
expect(overview.castle.remaining).toBe(661);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("completed dungeon has 0 remaining", () => {
|
|
28
|
+
const overview = dread("raid-218519.html").getOverview();
|
|
29
|
+
expect(overview.forest.remaining).toBe(0);
|
|
30
|
+
expect(overview.village.remaining).toBe(0);
|
|
31
|
+
expect(overview.castle.remaining).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("zone with no kills has 1000 remaining", async () => {
|
|
35
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
36
|
+
expect(new DreadsylvaniaRaid(log).getOverview().village.remaining).toBe(
|
|
37
|
+
1000,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("capacitor", () => {
|
|
43
|
+
test("detects fixed capacitor", () => {
|
|
44
|
+
expect(dread("cdr1-current.html").getOverview().capacitor).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test.skip("false when not fixed", () => {
|
|
48
|
+
// Needs a fixture with no fixed capacitor (none of ours have this)
|
|
49
|
+
expect(
|
|
50
|
+
new DreadsylvaniaRaid(
|
|
51
|
+
loadDreadFixture("no-capacitor.html"),
|
|
52
|
+
).getOverview().capacitor,
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("skills", () => {
|
|
58
|
+
test("3 remaining when none used", () => {
|
|
59
|
+
expect(dread("cdr1-current.html").getOverview().remainingSkills).toBe(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("0 remaining when all 3 used", () => {
|
|
63
|
+
expect(dread("raid-218286.html").getOverview().remainingSkills).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("partial usage", async () => {
|
|
67
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
68
|
+
expect(new DreadsylvaniaRaid(log).getOverview().remainingSkills).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("boss detection", () => {
|
|
74
|
+
test("no bosses defeated in active dungeon", () => {
|
|
75
|
+
const overview = dread("cdr1-current.html").getOverview();
|
|
76
|
+
expect(overview.forest.boss.status).not.toBe("defeated");
|
|
77
|
+
expect(overview.village.boss.status).not.toBe("defeated");
|
|
78
|
+
expect(overview.castle.boss.status).not.toBe("defeated");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("detects Falls-From-Sky", () => {
|
|
82
|
+
expect(dread("raid-218519.html").getBossStatus("forest")).toMatchObject({
|
|
83
|
+
name: "Falls-From-Sky",
|
|
84
|
+
status: "defeated",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detects The Great Wolf of the Air", () => {
|
|
89
|
+
expect(dread("raid-218286.html").getBossStatus("forest")).toMatchObject({
|
|
90
|
+
name: "The Great Wolf of the Air",
|
|
91
|
+
status: "defeated",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("detects The Zombie Homeowners' Association", () => {
|
|
96
|
+
expect(dread("raid-218519.html").getBossStatus("village")).toMatchObject({
|
|
97
|
+
name: "The Zombie Homeowners' Association",
|
|
98
|
+
status: "defeated",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("detects Count Drunkula", () => {
|
|
103
|
+
expect(dread("raid-218519.html").getBossStatus("castle")).toMatchObject({
|
|
104
|
+
name: "Count Drunkula",
|
|
105
|
+
status: "defeated",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("detects The Unkillable Skeleton", () => {
|
|
110
|
+
expect(dread("raid-218286.html").getBossStatus("castle")).toMatchObject({
|
|
111
|
+
name: "The Unkillable Skeleton",
|
|
112
|
+
status: "defeated",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("defeated bosses have confidence 1", () => {
|
|
117
|
+
expect(dread("raid-218519.html").getBossStatus("forest").confidence).toBe(
|
|
118
|
+
1,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("predicts boss from kill differential", async () => {
|
|
123
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
124
|
+
const boss = new DreadsylvaniaRaid(log).getBossStatus("castle");
|
|
125
|
+
expect(boss).toMatchObject({
|
|
126
|
+
name: "The Unkillable Skeleton",
|
|
127
|
+
status: "predicted",
|
|
128
|
+
});
|
|
129
|
+
expect(boss.confidence).toBeGreaterThan(0.5);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("detects Mayor Ghost", () => {
|
|
133
|
+
expect(dread("raid-217988.html").getBossStatus("village")).toMatchObject({
|
|
134
|
+
name: "Mayor Ghost",
|
|
135
|
+
status: "defeated",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("low-confidence prediction with few balanced kills", async () => {
|
|
140
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
141
|
+
const boss = new DreadsylvaniaRaid(log).getBossStatus("forest");
|
|
142
|
+
expect(boss.status).toBe("predicted");
|
|
143
|
+
expect(boss.confidence).toBeGreaterThan(0.5);
|
|
144
|
+
expect(boss.confidence).toBeLessThan(0.9);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("forest details", () => {
|
|
149
|
+
test("attic", () =>
|
|
150
|
+
expect(dread("raid-218286.html").getForestStatus().attic).toBe(true));
|
|
151
|
+
test("watchtower", () =>
|
|
152
|
+
expect(dread("raid-217988.html").getForestStatus().watchtower).toBe(true));
|
|
153
|
+
test("auditor badge", () =>
|
|
154
|
+
expect(dread("raid-218029.html").getForestStatus().auditor).toBe(true));
|
|
155
|
+
test("music box", () =>
|
|
156
|
+
expect(dread("raid-218286.html").getForestStatus().musicbox).toBe(true));
|
|
157
|
+
test("kiwi via 'knocked some fruit loose'", () =>
|
|
158
|
+
expect(dread("raid-218029.html").getForestStatus().kiwi).toBe(true));
|
|
159
|
+
test("kiwi via 'wasted some fruit'", () =>
|
|
160
|
+
expect(dread("raid-213013.html").getForestStatus().kiwi).toBe(true));
|
|
161
|
+
test("moon-amber", () =>
|
|
162
|
+
expect(dread("raid-218286.html").getForestStatus().amber).toBe(true));
|
|
163
|
+
|
|
164
|
+
test("nothing unlocked", () => {
|
|
165
|
+
expect(dread("cdr1-current.html").getForestStatus()).toEqual({
|
|
166
|
+
attic: false,
|
|
167
|
+
watchtower: false,
|
|
168
|
+
auditor: false,
|
|
169
|
+
musicbox: false,
|
|
170
|
+
kiwi: false,
|
|
171
|
+
amber: false,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("everything unlocked", () => {
|
|
176
|
+
expect(dread("raid-217988.html").getForestStatus()).toEqual({
|
|
177
|
+
attic: true,
|
|
178
|
+
watchtower: true,
|
|
179
|
+
auditor: true,
|
|
180
|
+
musicbox: true,
|
|
181
|
+
kiwi: true,
|
|
182
|
+
amber: true,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("village details", () => {
|
|
188
|
+
test("schoolhouse", () =>
|
|
189
|
+
expect(dread("cdr1-current.html").getVillageStatus().schoolhouse).toBe(
|
|
190
|
+
true,
|
|
191
|
+
));
|
|
192
|
+
test("master suite", () =>
|
|
193
|
+
expect(dread("raid-218519.html").getVillageStatus().suite).toBe(true));
|
|
194
|
+
test("hanging via 'hung'", () =>
|
|
195
|
+
expect(dread("raid-218205.html").getVillageStatus().hanging).toBe(true));
|
|
196
|
+
|
|
197
|
+
test("only schoolhouse unlocked", () => {
|
|
198
|
+
expect(dread("cdr2-current.html").getVillageStatus()).toEqual({
|
|
199
|
+
schoolhouse: true,
|
|
200
|
+
suite: false,
|
|
201
|
+
hanging: false,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("everything unlocked", () => {
|
|
206
|
+
expect(dread("raid-217988.html").getVillageStatus()).toEqual({
|
|
207
|
+
schoolhouse: true,
|
|
208
|
+
suite: true,
|
|
209
|
+
hanging: true,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("castle details", () => {
|
|
215
|
+
test("lab", () =>
|
|
216
|
+
expect(dread("cdr1-current.html").getCastleStatus().lab).toBe(true));
|
|
217
|
+
test("roast beast", () =>
|
|
218
|
+
expect(dread("raid-218286.html").getCastleStatus().roast).toBe(true));
|
|
219
|
+
test("wax banana", () =>
|
|
220
|
+
expect(dread("raid-218286.html").getCastleStatus().banana).toBe(true));
|
|
221
|
+
test("stinking agaricus", () =>
|
|
222
|
+
expect(dread("raid-218029.html").getCastleStatus().agaricus).toBe(true));
|
|
223
|
+
|
|
224
|
+
test("nothing unlocked except lab", () => {
|
|
225
|
+
expect(dread("cdr1-current.html").getCastleStatus()).toEqual({
|
|
226
|
+
lab: true,
|
|
227
|
+
roast: false,
|
|
228
|
+
banana: false,
|
|
229
|
+
agaricus: false,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("everything unlocked", () => {
|
|
234
|
+
expect(dread("raid-217988.html").getCastleStatus()).toEqual({
|
|
235
|
+
lab: true,
|
|
236
|
+
roast: true,
|
|
237
|
+
banana: true,
|
|
238
|
+
agaricus: true,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("participation", () => {
|
|
244
|
+
test("parses kills per player", async () => {
|
|
245
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
246
|
+
expect(
|
|
247
|
+
new DreadsylvaniaRaid(log).getParticipation()[2802400],
|
|
248
|
+
).toHaveProperty("kills", 423);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("parses skill uses", async () => {
|
|
252
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
253
|
+
expect(
|
|
254
|
+
new DreadsylvaniaRaid(log).getParticipation()[3137318],
|
|
255
|
+
).toHaveProperty("skills", 1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("player with only skill use has 0 kills", async () => {
|
|
259
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
260
|
+
expect(
|
|
261
|
+
new DreadsylvaniaRaid(log).getParticipation()[3137318],
|
|
262
|
+
).toHaveProperty("kills", 0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("counts single kills, multi-kills, and boss kills", () => {
|
|
266
|
+
const totalKills = Object.values(
|
|
267
|
+
dread("raid-218518.html").getParticipation(),
|
|
268
|
+
).reduce((sum, { kills }) => sum + kills, 0);
|
|
269
|
+
expect(totalKills).toBe(3003);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("only counts Dread kills from combined page", async () => {
|
|
273
|
+
const log = await loadFixture(import.meta.dirname, "raidlog.html");
|
|
274
|
+
const totalKills = Object.values(
|
|
275
|
+
new DreadsylvaniaRaid(log).getParticipation(),
|
|
276
|
+
).reduce((sum, { kills }) => sum + kills, 0);
|
|
277
|
+
expect(totalKills).toBe(511);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("returns empty for non-Dread log", () => {
|
|
281
|
+
expect(
|
|
282
|
+
Object.keys(
|
|
283
|
+
new DreadsylvaniaRaid("<html>Not a raid</html>").getParticipation(),
|
|
284
|
+
),
|
|
285
|
+
).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("mergeParticipation accumulates same player across sources", () => {
|
|
289
|
+
const merged = DreadsylvaniaRaid.mergeParticipation(
|
|
290
|
+
{ 123: { playerId: 123, kills: 10, skills: 0 } },
|
|
291
|
+
{ 123: { playerId: 123, kills: 5, skills: 1 } },
|
|
292
|
+
);
|
|
293
|
+
expect(merged[123]).toEqual({ playerId: 123, kills: 15, skills: 1 });
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("predictBoss", () => {
|
|
298
|
+
const MONSTERS = ["bugbear", "werewolf"] as const;
|
|
299
|
+
function kills(monster: 0 | 1, count: number): DreadEvent {
|
|
300
|
+
return {
|
|
301
|
+
type: "kill",
|
|
302
|
+
playerName: "Test",
|
|
303
|
+
playerId: 1,
|
|
304
|
+
monster: MONSTERS[monster],
|
|
305
|
+
count,
|
|
306
|
+
boss: false,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function banish(monster: 0 | 1): DreadEvent {
|
|
310
|
+
return {
|
|
311
|
+
type: "banish",
|
|
312
|
+
playerName: "Test",
|
|
313
|
+
playerId: 1,
|
|
314
|
+
monster: MONSTERS[monster],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function predict(events: DreadEvent[]) {
|
|
318
|
+
const raid = new DreadsylvaniaRaid("");
|
|
319
|
+
(raid as { events: DreadEvent[] }).events = events;
|
|
320
|
+
return raid.predictBoss("forest");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
test("no information gives 50/50 confidence", () => {
|
|
324
|
+
expect(predict([]).confidence).toBeCloseTo(0.5);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("one banish on m1 predicts m2 boss", () => {
|
|
328
|
+
const p = predict([banish(0)]);
|
|
329
|
+
expect(p.boss).toBe("werewolf");
|
|
330
|
+
expect(p.confidence).toBeGreaterThan(0.5);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("two banishes on m1 strongly predicts m2 boss", () => {
|
|
334
|
+
const p = predict([banish(0), banish(0)]);
|
|
335
|
+
expect(p.boss).toBe("werewolf");
|
|
336
|
+
expect(p.confidence).toBeCloseTo(1.0);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("equal banishes on both types gives 50/50", () => {
|
|
340
|
+
expect(predict([banish(0), banish(1)]).confidence).toBeCloseTo(0.5);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("kill ratio provides evidence with no banishes", () => {
|
|
344
|
+
const p = predict([kills(0, 300), kills(1, 200)]);
|
|
345
|
+
expect(p.boss).toBe("bugbear");
|
|
346
|
+
expect(p.confidence).toBeGreaterThan(0.5);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("more kills increase confidence", () => {
|
|
350
|
+
const few = predict([kills(0, 6), kills(1, 4)]);
|
|
351
|
+
const many = predict([kills(0, 600), kills(1, 400)]);
|
|
352
|
+
expect(few.boss).toBe(many.boss);
|
|
353
|
+
expect(many.confidence).toBeGreaterThan(few.confidence);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("banish and kills reinforce each other", () => {
|
|
357
|
+
const banishOnly = predict([banish(0)]);
|
|
358
|
+
const both = predict([kills(0, 100), banish(0), kills(1, 200)]);
|
|
359
|
+
expect(banishOnly.boss).toBe("werewolf");
|
|
360
|
+
expect(both.boss).toBe("werewolf");
|
|
361
|
+
expect(both.confidence).toBeGreaterThan(banishOnly.confidence);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("kills after a banish equalises ratio are less informative", () => {
|
|
365
|
+
expect(
|
|
366
|
+
predict([banish(0), kills(0, 50), kills(1, 50)]).confidence,
|
|
367
|
+
).toBeLessThan(0.75);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("kills before banish use pre-banish ratio", () => {
|
|
371
|
+
expect(predict([kills(0, 300), kills(1, 200), banish(0)]).boss).toBe(
|
|
372
|
+
"bugbear",
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("works for village zone", () => {
|
|
377
|
+
const raid = new DreadsylvaniaRaid("");
|
|
378
|
+
(raid as { events: DreadEvent[] }).events = [
|
|
379
|
+
{ type: "banish", playerName: "Test", playerId: 1, monster: "ghost" },
|
|
380
|
+
];
|
|
381
|
+
const p = raid.predictBoss("village");
|
|
382
|
+
expect(p.boss).toBe("zombie");
|
|
383
|
+
expect(p.confidence).toBeGreaterThan(0.5);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("works for castle zone", () => {
|
|
387
|
+
const raid = new DreadsylvaniaRaid("");
|
|
388
|
+
(raid as { events: DreadEvent[] }).events = [
|
|
389
|
+
{
|
|
390
|
+
type: "kill",
|
|
391
|
+
playerName: "Test",
|
|
392
|
+
playerId: 1,
|
|
393
|
+
monster: "vampire",
|
|
394
|
+
count: 100,
|
|
395
|
+
boss: false,
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
type: "kill",
|
|
399
|
+
playerName: "Test",
|
|
400
|
+
playerId: 1,
|
|
401
|
+
monster: "skeleton",
|
|
402
|
+
count: 300,
|
|
403
|
+
boss: false,
|
|
404
|
+
},
|
|
405
|
+
];
|
|
406
|
+
const p = raid.predictBoss("castle");
|
|
407
|
+
expect(p.boss).toBe("skeleton");
|
|
408
|
+
expect(p.confidence).toBeGreaterThan(0.5);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("defeat by boss gives confidence 1", () => {
|
|
412
|
+
const defeat: DreadEvent = {
|
|
413
|
+
type: "defeat",
|
|
414
|
+
playerName: "Test",
|
|
415
|
+
playerId: 1,
|
|
416
|
+
monster: "The Great Wolf of the Air",
|
|
417
|
+
count: 1,
|
|
418
|
+
boss: true,
|
|
419
|
+
};
|
|
420
|
+
const p = predict([kills(0, 50), kills(1, 50), defeat]);
|
|
421
|
+
expect(p.boss).toBe("werewolf");
|
|
422
|
+
expect(p.confidence).toBe(1);
|
|
423
|
+
});
|
|
424
|
+
});
|