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,550 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClanDungeon,
|
|
3
|
+
PLAYER_PREFIX,
|
|
4
|
+
parseLine,
|
|
5
|
+
type RaidLogEvent,
|
|
6
|
+
} from "./ClanDungeon.js";
|
|
7
|
+
|
|
8
|
+
export type { RaidLogEvent };
|
|
9
|
+
|
|
10
|
+
export type DreadBoss = {
|
|
11
|
+
name: string;
|
|
12
|
+
status: "predicted" | "defeated";
|
|
13
|
+
confidence: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DreadStatus = {
|
|
17
|
+
forest: { remaining: number; boss: DreadBoss };
|
|
18
|
+
village: { remaining: number; boss: DreadBoss };
|
|
19
|
+
castle: { remaining: number; boss: DreadBoss };
|
|
20
|
+
remainingSkills: number;
|
|
21
|
+
capacitor: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DreadForestStatus = {
|
|
25
|
+
attic: boolean;
|
|
26
|
+
watchtower: boolean;
|
|
27
|
+
auditor: boolean;
|
|
28
|
+
musicbox: boolean;
|
|
29
|
+
kiwi: boolean;
|
|
30
|
+
amber: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type DreadVillageStatus = {
|
|
34
|
+
schoolhouse: boolean;
|
|
35
|
+
suite: boolean;
|
|
36
|
+
hanging: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DreadCastleStatus = {
|
|
40
|
+
lab: boolean;
|
|
41
|
+
roast: boolean;
|
|
42
|
+
banana: boolean;
|
|
43
|
+
agaricus: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type DetailedDreadStatus = {
|
|
47
|
+
overview: DreadStatus;
|
|
48
|
+
forest: DreadForestStatus;
|
|
49
|
+
village: DreadVillageStatus;
|
|
50
|
+
castle: DreadCastleStatus;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type Participation = Record<
|
|
54
|
+
string | number,
|
|
55
|
+
{
|
|
56
|
+
skills: number;
|
|
57
|
+
kills: number;
|
|
58
|
+
playerId: number;
|
|
59
|
+
}
|
|
60
|
+
>;
|
|
61
|
+
|
|
62
|
+
const Monster = {
|
|
63
|
+
Bugbear: "bugbear",
|
|
64
|
+
Werewolf: "werewolf",
|
|
65
|
+
Ghost: "ghost",
|
|
66
|
+
Zombie: "zombie",
|
|
67
|
+
Vampire: "vampire",
|
|
68
|
+
Skeleton: "skeleton",
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
export type MonsterType = (typeof Monster)[keyof typeof Monster];
|
|
72
|
+
|
|
73
|
+
function pluralise(monster: MonsterType): string {
|
|
74
|
+
if (monster === "werewolf") return "werewolves";
|
|
75
|
+
return monster + "s";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const BOSS_NAMES: Record<MonsterType, string> = {
|
|
79
|
+
bugbear: "Falls-From-Sky",
|
|
80
|
+
werewolf: "The Great Wolf of the Air",
|
|
81
|
+
ghost: "Mayor Ghost",
|
|
82
|
+
zombie: "The Zombie Homeowners' Association",
|
|
83
|
+
vampire: "Count Drunkula",
|
|
84
|
+
skeleton: "The Unkillable Skeleton",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type DreadZone = "forest" | "village" | "castle";
|
|
88
|
+
|
|
89
|
+
const MONSTER_PAIRS: Record<DreadZone, readonly [MonsterType, MonsterType]> = {
|
|
90
|
+
forest: [Monster.Bugbear, Monster.Werewolf],
|
|
91
|
+
village: [Monster.Ghost, Monster.Zombie],
|
|
92
|
+
castle: [Monster.Vampire, Monster.Skeleton],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// --- Structured raid log events ---
|
|
96
|
+
|
|
97
|
+
export type DreadEvent =
|
|
98
|
+
| RaidLogEvent
|
|
99
|
+
| {
|
|
100
|
+
type: "banish";
|
|
101
|
+
playerName: string;
|
|
102
|
+
playerId: number;
|
|
103
|
+
monster: MonsterType;
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
type: "learned_skill";
|
|
107
|
+
playerName: string;
|
|
108
|
+
playerId: number;
|
|
109
|
+
helpers: [string, string];
|
|
110
|
+
}
|
|
111
|
+
| { type: "capacitor"; playerName: string; playerId: number }
|
|
112
|
+
| { type: "noncombat"; action: string };
|
|
113
|
+
|
|
114
|
+
const NONCOMBAT_ACTIONS = [
|
|
115
|
+
// Forest
|
|
116
|
+
"unlocked the attic of the cabin",
|
|
117
|
+
"unlocked the fire watchtower",
|
|
118
|
+
"got a Dreadsylvanian auditor's badge",
|
|
119
|
+
"made the forest less spooky",
|
|
120
|
+
"knocked some fruit loose",
|
|
121
|
+
"wasted some fruit",
|
|
122
|
+
"acquired a chunk of moon-amber",
|
|
123
|
+
"recycled some newspapers",
|
|
124
|
+
"found and sold a rare baseball card",
|
|
125
|
+
"got a cool seed pod",
|
|
126
|
+
"made an impression of a complicated lock",
|
|
127
|
+
"rifled through a footlocker",
|
|
128
|
+
"made some bone flour",
|
|
129
|
+
"acquired some dread tarragon",
|
|
130
|
+
"got a blood kiwi",
|
|
131
|
+
// Village
|
|
132
|
+
"unlocked the schoolhouse",
|
|
133
|
+
"unlocked the master suite",
|
|
134
|
+
"hung a clanmate",
|
|
135
|
+
"was hung by a clanmate",
|
|
136
|
+
"collected a ghost pencil",
|
|
137
|
+
"looted the blacksmith's till",
|
|
138
|
+
"robbed some graves",
|
|
139
|
+
"made a shepherd's pie",
|
|
140
|
+
"polished some moon-amber",
|
|
141
|
+
"looted the tinker's shack",
|
|
142
|
+
"made a complicated key",
|
|
143
|
+
"made a ghost shawl",
|
|
144
|
+
"got a bottle of eau de mort",
|
|
145
|
+
"swam in a sewer",
|
|
146
|
+
// Castle
|
|
147
|
+
"unlocked the lab",
|
|
148
|
+
"got some roast beast",
|
|
149
|
+
"got a wax banana",
|
|
150
|
+
"got some stinking agaric",
|
|
151
|
+
"unlocked the ballroom",
|
|
152
|
+
"twirled on the dance floor",
|
|
153
|
+
"sifted through some ashes",
|
|
154
|
+
"raided a dresser",
|
|
155
|
+
"made a blood kiwitini",
|
|
156
|
+
"made a cool iron ingot",
|
|
157
|
+
"made a cool iron breastplate",
|
|
158
|
+
// Miscellaneous
|
|
159
|
+
"got the carriageman",
|
|
160
|
+
] as const;
|
|
161
|
+
|
|
162
|
+
const ALL_MONSTER_TYPES = Object.values(Monster);
|
|
163
|
+
const MONSTER_PLURALS_PATTERN = ALL_MONSTER_TYPES.map(pluralise).join("|");
|
|
164
|
+
const BOSS_NAMES_LIST = Object.values(BOSS_NAMES);
|
|
165
|
+
|
|
166
|
+
const BANISH = new RegExp(
|
|
167
|
+
`^${PLAYER_PREFIX}drove some (${MONSTER_PLURALS_PATTERN}) out of the`,
|
|
168
|
+
"i",
|
|
169
|
+
);
|
|
170
|
+
const LEARNED_SKILL = new RegExp(
|
|
171
|
+
`^${PLAYER_PREFIX}used The Machine, assisted by (.+) and (.+)`,
|
|
172
|
+
"i",
|
|
173
|
+
);
|
|
174
|
+
const CAPACITOR = new RegExp(
|
|
175
|
+
`^${PLAYER_PREFIX}fixed The Machine \\(1 turn\\)`,
|
|
176
|
+
"i",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
function parseNumber(input?: string): number {
|
|
180
|
+
return parseInt(input?.replaceAll(",", "") || "0");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function matchBanish(line: string): DreadEvent | null {
|
|
184
|
+
const match = line.match(BANISH);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
const monster = ALL_MONSTER_TYPES.find(
|
|
187
|
+
(t) => pluralise(t) === match[3].toLowerCase(),
|
|
188
|
+
)!;
|
|
189
|
+
return {
|
|
190
|
+
type: "banish",
|
|
191
|
+
playerName: match[1].trim(),
|
|
192
|
+
playerId: parseInt(match[2]),
|
|
193
|
+
monster,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function matchLearnedSkill(line: string): DreadEvent | null {
|
|
198
|
+
const match = line.match(LEARNED_SKILL);
|
|
199
|
+
if (!match) return null;
|
|
200
|
+
return {
|
|
201
|
+
type: "learned_skill",
|
|
202
|
+
playerName: match[1].trim(),
|
|
203
|
+
playerId: parseInt(match[2]),
|
|
204
|
+
helpers: [match[3].trim(), match[4].trim()],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function matchCapacitor(line: string): DreadEvent | null {
|
|
209
|
+
const match = line.match(CAPACITOR);
|
|
210
|
+
if (!match) return null;
|
|
211
|
+
return {
|
|
212
|
+
type: "capacitor",
|
|
213
|
+
playerName: match[1].trim(),
|
|
214
|
+
playerId: parseInt(match[2]),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function matchNoncombat(line: string): DreadEvent | null {
|
|
219
|
+
const action = NONCOMBAT_ACTIONS.find((a) => line.includes(a));
|
|
220
|
+
return action ? { type: "noncombat", action } : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseEvents(raidLog: string): DreadEvent[] {
|
|
224
|
+
// Current raid pages combine all dungeons in separate divs.
|
|
225
|
+
// Historical raid pages are single-dungeon and have a title like
|
|
226
|
+
// "Dreadsylvania run, March 11, 2026".
|
|
227
|
+
const dreadBlock = raidLog.match(/<div id='Dreadsylvania'>([\s\S]*?)<\/div>/);
|
|
228
|
+
if (!dreadBlock && !raidLog.includes("Dreadsylvania run,")) return [];
|
|
229
|
+
const html = dreadBlock?.[1] ?? raidLog;
|
|
230
|
+
|
|
231
|
+
const events: DreadEvent[] = [];
|
|
232
|
+
|
|
233
|
+
for (const line of html.split(/<br\s*\/?>|\n/)) {
|
|
234
|
+
const trimmed = line.replace(/<[^>]*>/g, "").trim();
|
|
235
|
+
if (!trimmed) continue;
|
|
236
|
+
|
|
237
|
+
const event =
|
|
238
|
+
matchBanish(trimmed) ??
|
|
239
|
+
matchLearnedSkill(trimmed) ??
|
|
240
|
+
matchCapacitor(trimmed) ??
|
|
241
|
+
parseLine(trimmed, BOSS_NAMES_LIST) ??
|
|
242
|
+
matchNoncombat(trimmed);
|
|
243
|
+
|
|
244
|
+
if (event) events.push(event);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return events;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Represents a single Dreadsylvania raid instance with pre-parsed events.
|
|
252
|
+
* Construct via ClanDungeon service methods, or directly from HTML for testing.
|
|
253
|
+
*/
|
|
254
|
+
export class DreadsylvaniaRaid {
|
|
255
|
+
readonly events: DreadEvent[];
|
|
256
|
+
#raidLog: string;
|
|
257
|
+
|
|
258
|
+
constructor(raidLog: string) {
|
|
259
|
+
this.#raidLog = raidLog;
|
|
260
|
+
this.events = parseEvents(raidLog);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private hasAction(action: string): boolean {
|
|
264
|
+
return this.events.some(
|
|
265
|
+
(e) => e.type === "noncombat" && e.action === action,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Boss prediction ---
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Predict which boss will appear in a zone.
|
|
273
|
+
*
|
|
274
|
+
* The wiki model: each zone starts with an unknown 3:2 split between
|
|
275
|
+
* two monster types. Each explicit banish reduces a type's weight by 1.
|
|
276
|
+
* The type with the higher final weight produces the boss.
|
|
277
|
+
*
|
|
278
|
+
* We use Bayesian reasoning with two hypotheses for the initial split
|
|
279
|
+
* (H1: m1=3/m2=2, H2: m1=2/m2=3). Kill totals are evaluated against
|
|
280
|
+
* an average of the pre- and post-banish ratios since raid log events
|
|
281
|
+
* are not in global chronological order (they're grouped by zone then
|
|
282
|
+
* by player). This is conservative — it slightly underweights kill
|
|
283
|
+
* evidence when banishes are present, but never overestimates
|
|
284
|
+
* confidence.
|
|
285
|
+
*/
|
|
286
|
+
predictBoss(zone: DreadZone): { boss: MonsterType; confidence: number } {
|
|
287
|
+
const [m1, m2] = MONSTER_PAIRS[zone];
|
|
288
|
+
|
|
289
|
+
// Collect totals in a single pass
|
|
290
|
+
let banishesM1 = 0;
|
|
291
|
+
let banishesM2 = 0;
|
|
292
|
+
let killsM1 = 0;
|
|
293
|
+
let killsM2 = 0;
|
|
294
|
+
|
|
295
|
+
for (const event of this.events) {
|
|
296
|
+
switch (event.type) {
|
|
297
|
+
case "defeat": {
|
|
298
|
+
if (!event.boss) break;
|
|
299
|
+
const monster = [m1, m2].find((m) =>
|
|
300
|
+
event.monster.toLowerCase().includes(BOSS_NAMES[m].toLowerCase()),
|
|
301
|
+
);
|
|
302
|
+
if (monster) return { boss: monster, confidence: 1 };
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "banish": {
|
|
306
|
+
if (event.monster === m1) banishesM1++;
|
|
307
|
+
else if (event.monster === m2) banishesM2++;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "kill": {
|
|
311
|
+
if (event.boss) break;
|
|
312
|
+
const monsterLower = event.monster.toLowerCase();
|
|
313
|
+
if (monsterLower.includes(m1)) killsM1 += event.count;
|
|
314
|
+
else if (monsterLower.includes(m2)) killsM2 += event.count;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Final weights determine the boss
|
|
321
|
+
const finalM1H1 = Math.max(3 - banishesM1, 0);
|
|
322
|
+
const finalM2H1 = Math.max(2 - banishesM2, 0);
|
|
323
|
+
const finalM1H2 = Math.max(2 - banishesM1, 0);
|
|
324
|
+
const finalM2H2 = Math.max(3 - banishesM2, 0);
|
|
325
|
+
|
|
326
|
+
const bossUnderH1 =
|
|
327
|
+
finalM1H1 > finalM2H1 ? m1 : finalM2H1 > finalM1H1 ? m2 : null;
|
|
328
|
+
const bossUnderH2 =
|
|
329
|
+
finalM1H2 > finalM2H2 ? m1 : finalM2H2 > finalM1H2 ? m2 : null;
|
|
330
|
+
|
|
331
|
+
// If banishes are decisive (both hypotheses agree), no need for kill analysis
|
|
332
|
+
if (bossUnderH1 !== null && bossUnderH1 === bossUnderH2) {
|
|
333
|
+
return { boss: bossUnderH1, confidence: 1 };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Use average of initial and final ratios to account for kills
|
|
337
|
+
// happening across the entire run (before and after banishes)
|
|
338
|
+
const initialM1H1 = 3;
|
|
339
|
+
const initialM2H1 = 2;
|
|
340
|
+
const initialM1H2 = 2;
|
|
341
|
+
const initialM2H2 = 3;
|
|
342
|
+
|
|
343
|
+
const avgM1H1 = (initialM1H1 + finalM1H1) / 2;
|
|
344
|
+
const avgM2H1 = (initialM2H1 + finalM2H1) / 2;
|
|
345
|
+
const avgM1H2 = (initialM1H2 + finalM1H2) / 2;
|
|
346
|
+
const avgM2H2 = (initialM2H2 + finalM2H2) / 2;
|
|
347
|
+
|
|
348
|
+
const totalH1 = avgM1H1 + avgM2H1 || 1;
|
|
349
|
+
const totalH2 = avgM1H2 + avgM2H2 || 1;
|
|
350
|
+
|
|
351
|
+
const pM1H1 = avgM1H1 / totalH1;
|
|
352
|
+
const pM1H2 = avgM1H2 / totalH2;
|
|
353
|
+
|
|
354
|
+
// Log-likelihood of observed kill totals under each hypothesis
|
|
355
|
+
let logLikH1 = 0;
|
|
356
|
+
let logLikH2 = 0;
|
|
357
|
+
|
|
358
|
+
if (killsM1 + killsM2 > 0) {
|
|
359
|
+
if (pM1H1 > 0 && pM1H1 < 1 && pM1H2 > 0 && pM1H2 < 1) {
|
|
360
|
+
logLikH1 = killsM1 * Math.log(pM1H1) + killsM2 * Math.log(1 - pM1H1);
|
|
361
|
+
logLikH2 = killsM1 * Math.log(pM1H2) + killsM2 * Math.log(1 - pM1H2);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Posterior via log-sum-exp (equal prior)
|
|
366
|
+
const maxLog = Math.max(logLikH1, logLikH2);
|
|
367
|
+
const posteriorH1 = isFinite(maxLog)
|
|
368
|
+
? Math.exp(logLikH1 - maxLog) /
|
|
369
|
+
(Math.exp(logLikH1 - maxLog) + Math.exp(logLikH2 - maxLog))
|
|
370
|
+
: 0.5;
|
|
371
|
+
const posteriorH2 = 1 - posteriorH1;
|
|
372
|
+
|
|
373
|
+
// P(boss is m1) weighted across hypotheses
|
|
374
|
+
let pBossM1 = 0;
|
|
375
|
+
let pBossM2 = 0;
|
|
376
|
+
|
|
377
|
+
if (bossUnderH1 === m1) pBossM1 += posteriorH1;
|
|
378
|
+
else if (bossUnderH1 === m2) pBossM2 += posteriorH1;
|
|
379
|
+
else {
|
|
380
|
+
pBossM1 += posteriorH1 * 0.5;
|
|
381
|
+
pBossM2 += posteriorH1 * 0.5;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (bossUnderH2 === m1) pBossM1 += posteriorH2;
|
|
385
|
+
else if (bossUnderH2 === m2) pBossM2 += posteriorH2;
|
|
386
|
+
else {
|
|
387
|
+
pBossM1 += posteriorH2 * 0.5;
|
|
388
|
+
pBossM2 += posteriorH2 * 0.5;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (pBossM1 >= pBossM2) {
|
|
392
|
+
return { boss: m1, confidence: pBossM1 };
|
|
393
|
+
}
|
|
394
|
+
return { boss: m2, confidence: pBossM2 };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
getBossStatus(zone: DreadZone): DreadBoss {
|
|
398
|
+
const pair = MONSTER_PAIRS[zone];
|
|
399
|
+
|
|
400
|
+
for (const event of this.events) {
|
|
401
|
+
if (event.type !== "kill" || !event.boss) continue;
|
|
402
|
+
const monster = pair.find((m) =>
|
|
403
|
+
event.monster.toLowerCase().includes(BOSS_NAMES[m].toLowerCase()),
|
|
404
|
+
);
|
|
405
|
+
if (monster) {
|
|
406
|
+
return { name: BOSS_NAMES[monster], status: "defeated", confidence: 1 };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const prediction = this.predictBoss(zone);
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
name: BOSS_NAMES[prediction.boss],
|
|
414
|
+
status: "predicted",
|
|
415
|
+
confidence: prediction.confidence,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- Overview ---
|
|
420
|
+
|
|
421
|
+
getOverview(): DreadStatus {
|
|
422
|
+
const forest = this.#raidLog.match(
|
|
423
|
+
/Your clan has defeated <b>(?<forest>[\d,]+)<\/b> monster\(s\) in the Forest/,
|
|
424
|
+
);
|
|
425
|
+
const village = this.#raidLog.match(
|
|
426
|
+
/Your clan has defeated <b>(?<village>[\d,]+)<\/b> monster\(s\) in the Village/,
|
|
427
|
+
);
|
|
428
|
+
const castle = this.#raidLog.match(
|
|
429
|
+
/Your clan has defeated <b>(?<castle>[\d,]+)<\/b> monster\(s\) in the Castle/,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const skillCount = this.events.filter(
|
|
433
|
+
(e) => e.type === "learned_skill",
|
|
434
|
+
).length;
|
|
435
|
+
const capacitor = this.events.some((e) => e.type === "capacitor");
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
forest: {
|
|
439
|
+
remaining: 1000 - parseNumber(forest?.groups?.forest),
|
|
440
|
+
boss: this.getBossStatus("forest"),
|
|
441
|
+
},
|
|
442
|
+
village: {
|
|
443
|
+
remaining: 1000 - parseNumber(village?.groups?.village),
|
|
444
|
+
boss: this.getBossStatus("village"),
|
|
445
|
+
},
|
|
446
|
+
castle: {
|
|
447
|
+
remaining: 1000 - parseNumber(castle?.groups?.castle),
|
|
448
|
+
boss: this.getBossStatus("castle"),
|
|
449
|
+
},
|
|
450
|
+
remainingSkills: 3 - skillCount,
|
|
451
|
+
capacitor,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Zone details ---
|
|
456
|
+
|
|
457
|
+
getForestStatus(): DreadForestStatus {
|
|
458
|
+
return {
|
|
459
|
+
attic: this.hasAction("unlocked the attic of the cabin"),
|
|
460
|
+
watchtower: this.hasAction("unlocked the fire watchtower"),
|
|
461
|
+
auditor: this.hasAction("got a Dreadsylvanian auditor's badge"),
|
|
462
|
+
musicbox: this.hasAction("made the forest less spooky"),
|
|
463
|
+
kiwi:
|
|
464
|
+
this.hasAction("knocked some fruit loose") ||
|
|
465
|
+
this.hasAction("wasted some fruit"),
|
|
466
|
+
amber: this.hasAction("acquired a chunk of moon-amber"),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
getVillageStatus(): DreadVillageStatus {
|
|
471
|
+
return {
|
|
472
|
+
schoolhouse: this.hasAction("unlocked the schoolhouse"),
|
|
473
|
+
suite: this.hasAction("unlocked the master suite"),
|
|
474
|
+
hanging:
|
|
475
|
+
this.hasAction("hung a clanmate") ||
|
|
476
|
+
this.hasAction("was hung by a clanmate"),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
getCastleStatus(): DreadCastleStatus {
|
|
481
|
+
return {
|
|
482
|
+
lab: this.hasAction("unlocked the lab"),
|
|
483
|
+
roast: this.hasAction("got some roast beast"),
|
|
484
|
+
banana: this.hasAction("got a wax banana"),
|
|
485
|
+
agaricus: this.hasAction("got some stinking agaric"),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getDetailedStatus(): DetailedDreadStatus {
|
|
490
|
+
return {
|
|
491
|
+
overview: this.getOverview(),
|
|
492
|
+
forest: this.getForestStatus(),
|
|
493
|
+
village: this.getVillageStatus(),
|
|
494
|
+
castle: this.getCastleStatus(),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// --- Participation ---
|
|
499
|
+
|
|
500
|
+
getParticipation(): Participation {
|
|
501
|
+
const participation: Participation = {};
|
|
502
|
+
|
|
503
|
+
for (const event of this.events) {
|
|
504
|
+
if (event.type !== "kill" && event.type !== "learned_skill") continue;
|
|
505
|
+
const { playerId } = event;
|
|
506
|
+
const existing = participation[playerId] ?? {
|
|
507
|
+
playerId,
|
|
508
|
+
skills: 0,
|
|
509
|
+
kills: 0,
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (event.type === "kill") {
|
|
513
|
+
existing.kills += event.count;
|
|
514
|
+
} else {
|
|
515
|
+
existing.skills += 1;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
participation[playerId] = existing;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return participation;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
static mergeParticipation(...sources: Participation[]): Participation {
|
|
525
|
+
const result: Participation = {};
|
|
526
|
+
for (const source of sources) {
|
|
527
|
+
for (const [id, { playerId, skills, kills }] of Object.entries(source)) {
|
|
528
|
+
const existing = result[id] ?? { playerId, skills: 0, kills: 0 };
|
|
529
|
+
existing.skills += skills;
|
|
530
|
+
existing.kills += kills;
|
|
531
|
+
result[id] = existing;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- ClanDungeon convenience methods ---
|
|
539
|
+
|
|
540
|
+
export class DreadsylvaniaDungeon extends ClanDungeon {
|
|
541
|
+
async getRaid(clanId: number): Promise<DreadsylvaniaRaid>;
|
|
542
|
+
async getRaid(clanId: number, raidId: number): Promise<DreadsylvaniaRaid>;
|
|
543
|
+
async getRaid(clanId: number, raidId?: number): Promise<DreadsylvaniaRaid> {
|
|
544
|
+
const log =
|
|
545
|
+
raidId !== undefined
|
|
546
|
+
? await this.getRaidById(clanId, raidId)
|
|
547
|
+
: await this.getCurrentRaid(clanId);
|
|
548
|
+
return new DreadsylvaniaRaid(log);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Familiar-type effect calculations.
|
|
3
|
+
*
|
|
4
|
+
* KoL familiars provide bonuses that scale with their buffed weight.
|
|
5
|
+
* Different familiar types use different formulas. The "power"
|
|
6
|
+
* parameter accounts for equipment that multiplies the effect
|
|
7
|
+
* (e.g. a Comma Chameleon acting as a fairy has power 1, but some
|
|
8
|
+
* familiars have innate multipliers).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculate the +item drop percentage from a fairy-type familiar.
|
|
13
|
+
*
|
|
14
|
+
* Fairy-type familiars increase item drops from monsters. The formula
|
|
15
|
+
* is sqrt(55 * weight * power) + weight * power - 3.
|
|
16
|
+
*/
|
|
17
|
+
export function fairyItemDrop(weight: number, power = 1): number {
|
|
18
|
+
return Math.sqrt(55 * (weight * power)) + weight * power - 3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculate the familiar weight needed to reach a given +item drop bonus.
|
|
23
|
+
*
|
|
24
|
+
* Inverse of fairyItemDrop — given a desired item drop percentage,
|
|
25
|
+
* returns the weight needed to achieve it.
|
|
26
|
+
*/
|
|
27
|
+
export function fairyWeightForItemDrop(modifier: number, power = 1): number {
|
|
28
|
+
return (2 * modifier + 61 - Math.sqrt(220 * modifier + 3685)) / (2 * power);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Calculate the +meat drop percentage from a leprechaun-type familiar.
|
|
33
|
+
*
|
|
34
|
+
* Meat drop bonus = sqrt(220 * weight) + 2 * weight - 6
|
|
35
|
+
* This is exactly 2x the fairy item drop formula.
|
|
36
|
+
*/
|
|
37
|
+
export function leprechaunMeatDrop(weight: number, power = 1): number {
|
|
38
|
+
return 2 * fairyItemDrop(weight, power);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Calculate the familiar weight needed to reach a given +meat drop bonus.
|
|
43
|
+
*
|
|
44
|
+
* Inverse of leprechaunMeatDrop — halves the target then uses the
|
|
45
|
+
* fairy inverse since meat drop = 2 * fairy item drop.
|
|
46
|
+
*/
|
|
47
|
+
export function leprechaunWeightForMeatDrop(
|
|
48
|
+
modifier: number,
|
|
49
|
+
power = 1,
|
|
50
|
+
): number {
|
|
51
|
+
return fairyWeightForItemDrop(modifier / 2, power);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calculate substats per combat from a volleyball-type familiar.
|
|
56
|
+
*
|
|
57
|
+
* Volleyball-type familiars grant a flat substat bonus per combat
|
|
58
|
+
* that scales linearly with weight.
|
|
59
|
+
*/
|
|
60
|
+
export function volleyballSubstats(weight: number): number {
|
|
61
|
+
return 2 + 0.2 * weight;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Calculate substats per combat from a sombrero-type familiar.
|
|
66
|
+
*
|
|
67
|
+
* Sombrero-type familiars grant substats that scale with both the
|
|
68
|
+
* familiar's weight and the monster level (ML) of the enemy fought.
|
|
69
|
+
*/
|
|
70
|
+
export function sombreroSubstats(weight: number, ml: number): number {
|
|
71
|
+
return (ml / 4) * (0.1 + 0.005 * weight);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Calculate the +item drop bonus needed to cap a given drop rate.
|
|
76
|
+
*
|
|
77
|
+
* In KoL, item drops can be "capped" at 100% with enough +item.
|
|
78
|
+
* This returns the minimum +item% needed to guarantee the drop.
|
|
79
|
+
*/
|
|
80
|
+
export function itemDropBonusToCap(dropRate: number): number {
|
|
81
|
+
return Math.ceil(10000 / dropRate) - 100;
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { loadFixture } from "../testUtils.js";
|
|
4
|
+
import { FloralMercantileExchange } from "./FloralMercantileExchange.js";
|
|
5
|
+
|
|
6
|
+
test("Can read prices", async () => {
|
|
7
|
+
const page = await loadFixture(import.meta.dirname, "flowers.html");
|
|
8
|
+
|
|
9
|
+
const prices = FloralMercantileExchange.parse(page);
|
|
10
|
+
|
|
11
|
+
expect(prices).toHaveProperty("red", 11);
|
|
12
|
+
expect(prices).toHaveProperty("white", 11);
|
|
13
|
+
expect(prices).toHaveProperty("blue", 12);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("Returns null if prices cannot be read", () => {
|
|
17
|
+
const prices = FloralMercantileExchange.parse("");
|
|
18
|
+
|
|
19
|
+
expect(prices).toBe(null);
|
|
20
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Client } from "../Client.js";
|
|
2
|
+
|
|
3
|
+
export type FlowerPrices = {
|
|
4
|
+
red: number;
|
|
5
|
+
white: number;
|
|
6
|
+
blue: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class FloralMercantileExchangeParseError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Could not parse flower prices");
|
|
12
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class FloralMercantileExchange {
|
|
17
|
+
#client: Client;
|
|
18
|
+
|
|
19
|
+
constructor(client: Client) {
|
|
20
|
+
this.#client = client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getPrices(): Promise<FlowerPrices> {
|
|
24
|
+
const page = await this.#client.fetchText(
|
|
25
|
+
"shop.php?whichshop=flowertradein",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const result = FloralMercantileExchange.parse(page);
|
|
29
|
+
if (!result) throw new FloralMercantileExchangeParseError();
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static parse(page: string): FlowerPrices | null {
|
|
34
|
+
const pattern =
|
|
35
|
+
/<tr rel="7567">.*?Chroner<\/b> <b>\((\d+)\)<\/b>.*?descitem\((\d+)\).*?<\/tr>/gs;
|
|
36
|
+
const matches = [...page.matchAll(pattern)];
|
|
37
|
+
|
|
38
|
+
if (matches.length !== 3) return null;
|
|
39
|
+
|
|
40
|
+
const prices = matches.reduce<Record<string, number>>(
|
|
41
|
+
(acc, m) => ({ ...acc, [m[2]]: Number(m[1]) }),
|
|
42
|
+
{},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
red: prices["973996072"] ?? 0,
|
|
47
|
+
white: prices["156741343"] ?? 0,
|
|
48
|
+
blue: prices["126513532"] ?? 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|