Skip to content

Commit 8d08d58

Browse files
authored
Add IotM tracking infrastructure (#300)
* Add IotM tracking: MrStore domain, database table, and live tracking - New MrStore kol.js domain that parses mrstore.php for item name, descid, image, cost, currency, and category - New Iotm database table with migration tracking item lifecycle dates (addedToStore, removedFromStore, distributedToSubscribers) - Store check on rollover event to detect new/removed IotMs - Subs webhook now records distributedToSubscribers date - Refactor determineIotmMonthYear() to determineIotmMonth() returning a Date * Rename IotM migration to timestamp format Matches the naming convention of the flower_prices_created_at_index migration that was added on main. * Seed IotM table with historical data, make mraCost non-nullable Migration now inserts 253 IotMs with names, descids, images, store dates (from kol-exchange CSV), and 32 subscriber distribution dates (from Discord channel history). mraCost is NOT NULL DEFAULT 1.
1 parent 8263e4f commit 8d08d58

9 files changed

Lines changed: 686 additions & 11 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from "vitest";
2+
3+
import { loadFixture } from "../testUtils.js";
4+
import { MrStore } from "./MrStore.js";
5+
6+
test("Can parse Mr. Store items", async () => {
7+
const page = await loadFixture(import.meta.dirname, "mrstore.html");
8+
9+
const items = MrStore.parse(page);
10+
11+
expect(items.length).toBeGreaterThan(0);
12+
13+
const iotm = items.find((i) => i.category === "April's Item-of-the-Month");
14+
expect(iotm).toBeDefined();
15+
expect(iotm).toMatchObject({
16+
name: "wrapped Baseball Diamond",
17+
descid: 844091703,
18+
image: "bdiamond_box.gif",
19+
cost: 1,
20+
currency: "mr_accessory",
21+
category: "April's Item-of-the-Month",
22+
});
23+
});
24+
25+
test("Parses item-of-the-year", async () => {
26+
const page = await loadFixture(import.meta.dirname, "mrstore.html");
27+
28+
const items = MrStore.parse(page);
29+
30+
const ioty = items.find((i) => i.category === "2026's Item-of-the-Year");
31+
expect(ioty).toMatchObject({
32+
name: "discreetly-wrapped Eternity Codpiece",
33+
descid: 323763723,
34+
cost: 1,
35+
currency: "mr_accessory",
36+
});
37+
});
38+
39+
test("Parses uncle buck items", async () => {
40+
const page = await loadFixture(import.meta.dirname, "mrstore.html");
41+
42+
const items = MrStore.parse(page);
43+
44+
const ubItem = items.find((i) => i.currency === "uncle_buck");
45+
expect(ubItem).toBeDefined();
46+
});
47+
48+
test("Returns empty array for unparseable input", () => {
49+
const items = MrStore.parse("");
50+
expect(items).toEqual([]);
51+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Client } from "../Client.js";
2+
3+
export type MrStoreItem = {
4+
name: string;
5+
descid: number;
6+
image: string;
7+
cost: number;
8+
currency: "mr_accessory" | "uncle_buck";
9+
category: string;
10+
};
11+
12+
export class MrStore {
13+
#client: Client;
14+
15+
constructor(client: Client) {
16+
this.#client = client;
17+
}
18+
19+
async getCurrentItems(): Promise<MrStoreItem[]> {
20+
const page = await this.#client.fetchText("mrstore.php");
21+
return MrStore.parse(page);
22+
}
23+
24+
static parse(page: string): MrStoreItem[] {
25+
const boxPattern =
26+
/<div\s+id=div\d+\s+width=320\s+class=mrbox>([\s\S]*?)<\/div>/g;
27+
28+
const items: MrStoreItem[] = [];
29+
30+
for (const boxMatch of page.matchAll(boxPattern)) {
31+
const box = boxMatch[1];
32+
33+
const category = box.match(/<b style="color: white">(.+?)<\/b>/)?.[1];
34+
const descid = box.match(/descitem\((\d+)\)/)?.[1];
35+
const name = box.match(/class=nounder>(.+?)<\/a>/)?.[1];
36+
const image = box.match(/itemimages\/(.+?\.gif)/)?.[1];
37+
const cost = box.match(/<font size=\+1>(\d+)<\/font>/)?.[1];
38+
const isUncleBuck = box.includes("unclebuck.gif");
39+
40+
if (!category || !descid || !name || !image || !cost) continue;
41+
42+
items.push({
43+
name,
44+
descid: Number(descid),
45+
image,
46+
cost: Number(cost),
47+
currency: isUncleBuck ? "uncle_buck" : "mr_accessory",
48+
category,
49+
});
50+
}
51+
52+
return items;
53+
}
54+
}

packages/kol.js/src/domains/__fixtures__/mrstore.html

Lines changed: 59 additions & 0 deletions
Large diffs are not rendered by default.

packages/oaf/migrations/1776100000000_iotm.ts

Lines changed: 284 additions & 0 deletions
Large diffs are not rendered by default.

packages/oaf/src/clients/database.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,3 +994,131 @@ export async function getLatestDailies() {
994994
`.execute(db);
995995
return results.rows;
996996
}
997+
998+
// ── Iotm ──
999+
1000+
export async function upsertIotmByName(data: {
1001+
itemName: string;
1002+
itemDescid?: number | null;
1003+
itemImage?: string | null;
1004+
month: Date;
1005+
mraCost?: number;
1006+
subscriberItem?: boolean;
1007+
addedToStore?: Date | null;
1008+
}) {
1009+
// Check if there's an existing nameless subscriber row for this month we should claim
1010+
const existing = await db
1011+
.selectFrom("Iotm")
1012+
.selectAll()
1013+
.where("month", "=", data.month)
1014+
.where("subscriberItem", "=", data.subscriberItem ?? false)
1015+
.where("itemName", "is", null)
1016+
.executeTakeFirst();
1017+
1018+
if (existing) {
1019+
await db
1020+
.updateTable("Iotm")
1021+
.set({
1022+
itemName: data.itemName,
1023+
itemDescid: data.itemDescid ?? null,
1024+
itemImage: data.itemImage ?? null,
1025+
mraCost: data.mraCost,
1026+
addedToStore: data.addedToStore ?? null,
1027+
})
1028+
.where("id", "=", existing.id)
1029+
.execute();
1030+
return;
1031+
}
1032+
1033+
await db
1034+
.insertInto("Iotm")
1035+
.values({
1036+
itemName: data.itemName,
1037+
itemDescid: data.itemDescid ?? null,
1038+
itemImage: data.itemImage ?? null,
1039+
month: data.month,
1040+
...(data.mraCost !== undefined && { mraCost: data.mraCost }),
1041+
subscriberItem: data.subscriberItem ?? false,
1042+
addedToStore: data.addedToStore ?? null,
1043+
})
1044+
.onConflict((oc) =>
1045+
oc.column("itemName").doUpdateSet({
1046+
itemDescid: data.itemDescid ?? null,
1047+
itemImage: data.itemImage ?? null,
1048+
...(data.mraCost !== undefined && { mraCost: data.mraCost }),
1049+
addedToStore: data.addedToStore ?? null,
1050+
}),
1051+
)
1052+
.execute();
1053+
}
1054+
1055+
export async function upsertSubsRoll(month: Date, distributedAt: Date) {
1056+
const existing = await db
1057+
.selectFrom("Iotm")
1058+
.selectAll()
1059+
.where("month", "=", month)
1060+
.where("subscriberItem", "=", true)
1061+
.executeTakeFirst();
1062+
1063+
if (existing) {
1064+
await db
1065+
.updateTable("Iotm")
1066+
.set({ distributedToSubscribers: distributedAt })
1067+
.where("id", "=", existing.id)
1068+
.execute();
1069+
return;
1070+
}
1071+
1072+
await db
1073+
.insertInto("Iotm")
1074+
.values({
1075+
month,
1076+
subscriberItem: true,
1077+
distributedToSubscribers: distributedAt,
1078+
})
1079+
.execute();
1080+
}
1081+
1082+
export async function setIotmRemovedFromStore(
1083+
itemName: string,
1084+
removedAt: Date,
1085+
) {
1086+
await db
1087+
.updateTable("Iotm")
1088+
.set({ removedFromStore: removedAt })
1089+
.where("itemName", "=", itemName)
1090+
.where("removedFromStore", "is", null)
1091+
.execute();
1092+
}
1093+
1094+
export async function getIotmsInStore() {
1095+
return await db
1096+
.selectFrom("Iotm")
1097+
.selectAll()
1098+
.where("addedToStore", "is not", null)
1099+
.where("removedFromStore", "is", null)
1100+
.where("itemName", "is not", null)
1101+
.execute();
1102+
}
1103+
1104+
export async function getIotmsForDateRange(from: Date, to: Date) {
1105+
return await db
1106+
.selectFrom("Iotm")
1107+
.selectAll()
1108+
.where((eb) =>
1109+
eb.or([
1110+
// Added to store within the range
1111+
eb.and([eb("addedToStore", ">=", from), eb("addedToStore", "<=", to)]),
1112+
// In store during the range (added before, removed after or still in)
1113+
eb.and([
1114+
eb("addedToStore", "<=", to),
1115+
eb.or([
1116+
eb("removedFromStore", "is", null),
1117+
eb("removedFromStore", ">=", from),
1118+
]),
1119+
]),
1120+
]),
1121+
)
1122+
.orderBy("month", "asc")
1123+
.execute();
1124+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { MrStore } from "kol.js/domains/MrStore";
2+
3+
import {
4+
getIotmsInStore,
5+
setIotmRemovedFromStore,
6+
upsertIotmByName,
7+
} from "../../clients/database.js";
8+
import { kolClient } from "../../clients/kol.js";
9+
import { determineIotmMonth } from "../../server/apps/oaf/routes/subs.js";
10+
11+
const IOTM_CATEGORY_PATTERN = /^.+'s Item-of-the-Month$/;
12+
13+
const mrStore = new MrStore(kolClient);
14+
15+
async function checkStore() {
16+
let items;
17+
try {
18+
items = await mrStore.getCurrentItems();
19+
} catch {
20+
return;
21+
}
22+
23+
if (items.length === 0) return;
24+
25+
const now = new Date();
26+
const month = determineIotmMonth();
27+
28+
const currentNames = new Set<string>();
29+
30+
for (const item of items) {
31+
currentNames.add(item.name);
32+
33+
const subscriberItem = IOTM_CATEGORY_PATTERN.test(item.category);
34+
35+
await upsertIotmByName({
36+
itemName: item.name,
37+
itemDescid: item.descid,
38+
itemImage: item.image,
39+
month,
40+
mraCost: item.cost,
41+
subscriberItem,
42+
addedToStore: now,
43+
});
44+
}
45+
46+
// Mark any previously-tracked items no longer in the store as removed
47+
const inStore = await getIotmsInStore();
48+
for (const iotm of inStore) {
49+
if (iotm.itemName && !currentNames.has(iotm.itemName)) {
50+
await setIotmRemovedFromStore(iotm.itemName, now);
51+
}
52+
}
53+
}
54+
55+
export function init() {
56+
kolClient.on("rollover", () => void checkStore());
57+
}

packages/oaf/src/database-types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,31 @@ export interface DailySubmissionTable {
105105
submittedAt: ColumnType<Date, Date | undefined, Date>;
106106
}
107107

108+
export interface IotmTable {
109+
id: Generated<number>;
110+
itemName: string | null;
111+
itemDescid: number | null;
112+
itemImage: string | null;
113+
month: ColumnType<Date, Date | string, Date | string>;
114+
mraCost: Generated<number>;
115+
subscriberItem: Generated<boolean>;
116+
addedToStore: ColumnType<
117+
Date | null,
118+
Date | string | null,
119+
Date | string | null
120+
>;
121+
removedFromStore: ColumnType<
122+
Date | null,
123+
Date | string | null,
124+
Date | string | null
125+
>;
126+
distributedToSubscribers: ColumnType<
127+
Date | null,
128+
Date | string | null,
129+
Date | string | null
130+
>;
131+
}
132+
108133
export interface DB {
109134
Player: PlayerTable;
110135
StandingOffer: StandingOfferTable;
@@ -119,6 +144,7 @@ export interface DB {
119144
FlowerPriceAlert: FlowerPriceAlertTable;
120145
Daily: DailyTable;
121146
DailySubmission: DailySubmissionTable;
147+
Iotm: IotmTable;
122148
}
123149

124150
export type Player = Selectable<PlayerTable>;
@@ -134,3 +160,4 @@ export type FlowerPrices = Selectable<FlowerPricesTable>;
134160
export type FlowerPriceAlert = Selectable<FlowerPriceAlertTable>;
135161
export type Daily = Selectable<DailyTable>;
136162
export type DailySubmission = Selectable<DailySubmissionTable>;
163+
export type Iotm = Selectable<IotmTable>;
Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { afterEach, beforeEach } from "node:test";
22
import { describe, expect, test, vi } from "vitest";
33

4-
import { determineIotmMonthYear } from "./subs.js";
4+
import { determineIotmMonth, formatIotmMonth } from "./subs.js";
55

6-
describe("Can parse dates correctly", () => {
6+
describe("determineIotmMonth", () => {
77
beforeEach(() => {
88
vi.useFakeTimers();
99
});
@@ -14,16 +14,22 @@ describe("Can parse dates correctly", () => {
1414

1515
test("Correctly rolls forward to the next month", () => {
1616
vi.setSystemTime(new Date(2023, 9, 27));
17-
expect(determineIotmMonthYear()).toEqual("November 2023");
17+
expect(determineIotmMonth()).toEqual(new Date(2023, 10, 1));
1818
});
1919

2020
test("Correctly rolls backward to the current month", () => {
2121
vi.setSystemTime(new Date(2023, 9, 6));
22-
expect(determineIotmMonthYear()).toEqual("October 2023");
22+
expect(determineIotmMonth()).toEqual(new Date(2023, 9, 1));
2323
});
2424

2525
test("Correctly rolls forward to the next year", () => {
2626
vi.setSystemTime(new Date(2023, 11, 30));
27-
expect(determineIotmMonthYear()).toEqual("January 2024");
27+
expect(determineIotmMonth()).toEqual(new Date(2024, 0, 1));
28+
});
29+
});
30+
31+
describe("formatIotmMonth", () => {
32+
test("Formats a month correctly", () => {
33+
expect(formatIotmMonth(new Date(2023, 9, 1))).toEqual("October 2023");
2834
});
2935
});

0 commit comments

Comments
 (0)