├── .yarnrc.yml
├── app
├── components
│ ├── Monsters.module.css
│ ├── LastUpdate.module.css
│ ├── Footer.module.css
│ ├── Header.module.css
│ ├── Settings.module.css
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── TotalProgress.module.css
│ ├── History.module.css
│ ├── ProgressBar.module.css
│ ├── LastUpdate.tsx
│ ├── Button.tsx
│ ├── Tabbar.module.css
│ ├── ProgressBar.tsx
│ ├── Button.module.css
│ ├── Settings.tsx
│ ├── Monster.module.css
│ ├── Monsters.tsx
│ ├── Tabbar.tsx
│ ├── TotalProgress.tsx
│ ├── History.tsx
│ └── Monster.tsx
├── routes.ts
├── routes
│ ├── status.ts
│ ├── monsters.ts
│ └── home.tsx
├── database.ts
├── index.css
├── root.tsx
├── priorities.ts
└── update.ts
├── .vscode
└── settings.json
├── public
└── favicon.png
├── prisma
├── migrations
│ ├── migration_lock.toml
│ ├── 20250829205829_add_relation_between_monitor_and_history
│ │ └── migration.sql
│ ├── 20250611081117_init
│ │ └── migration.sql
│ └── 20250824153504_dedupe_history
│ │ └── migration.sql
└── schema.prisma
├── react-router.config.ts
├── .env.template
├── .gitignore
├── vite.config.ts
├── tsconfig.json
├── README.md
└── package.json
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/app/components/Monsters.module.css:
--------------------------------------------------------------------------------
1 | .monsters {
2 | margin-top: 1em;
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loathers/eggnet/main/public/favicon.png
--------------------------------------------------------------------------------
/app/components/LastUpdate.module.css:
--------------------------------------------------------------------------------
1 | .lastUpdate {
2 | font-size: 0.8em;
3 | margin: 0.8em;
4 | }
5 |
--------------------------------------------------------------------------------
/app/components/Footer.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | font-size: 0.8em;
3 | margin: 0.8em;
4 | text-align: right;
5 | }
6 |
--------------------------------------------------------------------------------
/app/components/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | font-size: 2em;
3 | margin: 0.5em;
4 | text-align: center;
5 | }
6 |
--------------------------------------------------------------------------------
/app/components/Settings.module.css:
--------------------------------------------------------------------------------
1 | .settings {
2 | margin-top: 1em;
3 | margin-left: 1em;
4 | margin-right: 1em;
5 | }
6 |
--------------------------------------------------------------------------------
/app/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Header.module.css";
2 |
3 | export function Header() {
4 | return
EggNet Monitor
;
5 | }
6 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
4 |
--------------------------------------------------------------------------------
/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Footer.module.css";
2 |
3 | export function Footer() {
4 | return Made by Semenar (#3275442)
;
5 | }
6 |
--------------------------------------------------------------------------------
/app/components/TotalProgress.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | margin: 1em;
3 | gap: 0.5em;
4 | }
5 |
6 | .progressbarContainer {
7 | height: 1.5em;
8 | background-color: var(--color-content-pane);
9 | }
10 |
--------------------------------------------------------------------------------
/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "@react-router/dev/config";
2 |
3 | export default {
4 | // Config options...
5 | // Server-side render by default, to enable SPA mode set this to `false`
6 | ssr: true,
7 | } satisfies Config;
8 |
--------------------------------------------------------------------------------
/app/components/History.module.css:
--------------------------------------------------------------------------------
1 | .tooltip {
2 | background-color: var(--color-tab-active);
3 | border-radius: 4px;
4 | padding: 8px 16px;
5 | z-index: 1000;
6 | visibility: hidden;
7 |
8 | &.visible {
9 | visibility: visible;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/routes.ts:
--------------------------------------------------------------------------------
1 | import { type RouteConfig, index, route } from "@react-router/dev/routes";
2 |
3 | export default [
4 | index("routes/home.tsx"),
5 | route("/status", "routes/status.ts"),
6 | route("/monsters", "routes/monsters.ts"),
7 | ] satisfies RouteConfig;
8 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | # Kingdom of Loathing credentials
2 | KOL_USERNAME=your_kol_username
3 | KOL_PASSWORD=your_kol_password
4 |
5 | # Database configuration
6 | DATABASE_URL=
7 |
8 | # OAF webhook token
9 | OAF_TOKEN=
10 |
11 | # Server configuration (optional)
12 | PORT=3000
13 |
--------------------------------------------------------------------------------
/app/components/ProgressBar.module.css:
--------------------------------------------------------------------------------
1 | .progressbar {
2 | width: 100%;
3 | height: 100%;
4 | text-align: center;
5 | background: linear-gradient(
6 | to right,
7 | var(--color-selected) var(--percentage),
8 | rgba(0, 0, 0, 0) var(--percentage)
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/prisma/migrations/20250829205829_add_relation_between_monitor_and_history/migration.sql:
--------------------------------------------------------------------------------
1 | -- AddForeignKey
2 | ALTER TABLE "public"."EggnetMonitorHistory" ADD CONSTRAINT "EggnetMonitorHistory_monster_id_fkey" FOREIGN KEY ("monster_id") REFERENCES "public"."EggnetMonitor"("monster_id") ON DELETE RESTRICT ON UPDATE CASCADE;
3 |
--------------------------------------------------------------------------------
/app/routes/status.ts:
--------------------------------------------------------------------------------
1 | import { data } from "react-router";
2 | import { getEggStatus } from "~/database.js";
3 |
4 | export async function loader() {
5 | try {
6 | return await getEggStatus();
7 | } catch (error) {
8 | console.error("Error fetching egg status:", error);
9 | return data({ error: "Internal server error" }, 500);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Node.js
2 | .DS_Store
3 | /node_modules/
4 |
5 | # React Router
6 | /.react-router/
7 | /build/
8 |
9 | # Package manager
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | .pnp.*
14 | .yarn/*
15 | !.yarn/patches
16 | !.yarn/plugins
17 | !.yarn/releases
18 | !.yarn/sdks
19 | !.yarn/versions
20 |
21 | # Environment files
22 | .env
23 | .env.local
24 | .env.production
25 |
26 |
--------------------------------------------------------------------------------
/app/components/LastUpdate.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./LastUpdate.module.css";
2 |
3 | type Props = {
4 | date: Date;
5 | };
6 |
7 | export function LastUpdate({ date }: Props) {
8 | return (
9 |
10 | Last update:{" "}
11 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import styles from "./Button.module.css";
3 |
4 | type Props = React.PropsWithChildren<{
5 | onClick: () => void;
6 | active?: boolean;
7 | }>;
8 |
9 | export function Button({ children, onClick, active }: Props) {
10 | return (
11 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/components/Tabbar.module.css:
--------------------------------------------------------------------------------
1 | .tabbar {
2 | display: grid;
3 | grid-template-columns: repeat(4, 1fr);
4 | grid-auto-rows: 1fr;
5 | gap: 1em;
6 | margin-left: 1em;
7 | margin-right: 1em;
8 | }
9 |
10 | @media only screen and (max-width: 1200px) {
11 | .tabbar {
12 | grid-template-columns: repeat(2, 1fr);
13 | }
14 | }
15 |
16 | @media only screen and (max-width: 600px) {
17 | .tabbar {
18 | display: flex;
19 | flex-direction: column;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from "@react-router/dev/vite";
2 | import { defineConfig } from "vite";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 |
5 | export default defineConfig({
6 | ssr: {
7 | optimizeDeps: {
8 | include: ["@prisma/client-generated"],
9 | },
10 | },
11 | plugins: [reactRouter(), tsconfigPaths()],
12 | build: {
13 | rollupOptions: {
14 | external: ["@prisma/client-generated"], // 👈 Also here
15 | },
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/app/components/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./ProgressBar.module.css";
2 |
3 | type Props = React.PropsWithChildren<{
4 | progress: [current: number, total: number];
5 | }>;
6 |
7 | export function ProgressBar({ progress, children }: Props) {
8 | return (
9 |
18 | {children}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | border-radius: 1em;
3 | padding: 0.5em;
4 | /*margin-top: 1em;
5 | margin-bottom: 1em;*/
6 | color: inherit;
7 | font-size: inherit;
8 | font-family: inherit;
9 | background-color: var(--color-tab-inactive);
10 | box-shadow: 0.3em 0.3em var(--color-tab-inactive-shadow);
11 | cursor: pointer;
12 | text-align: center;
13 | user-select: none;
14 | border: 0 none;
15 | }
16 |
17 | .button.active {
18 | background-color: var(--color-tab-active);
19 | box-shadow: 0.3em 0.3em var(--color-tab-active-shadow);
20 | }
21 |
--------------------------------------------------------------------------------
/app/routes/monsters.ts:
--------------------------------------------------------------------------------
1 | import { data } from "react-router";
2 | import { prisma } from "~/database.js";
3 | import { priorities } from "~/priorities.js";
4 |
5 | export async function loader() {
6 | try {
7 | const monsters = await prisma.eggnetMonitor.findMany({});
8 | return monsters.map((m) => ({
9 | id: m.monster_id,
10 | eggs: m.eggs_donated,
11 | priority: priorities[m.monster_id] ?? 0,
12 | }));
13 | } catch (error) {
14 | console.error("Error fetching egg status:", error);
15 | return data({ error: "Internal server error" }, 500);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/Settings.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Settings.module.css";
2 |
3 | type Props = {
4 | hideCompleted: boolean;
5 | onChangeHideCompleted: (value: boolean) => void;
6 | };
7 |
8 | export function Settings({ hideCompleted, onChangeHideCompleted }: Props) {
9 | return (
10 |
11 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/prisma/migrations/20250611081117_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "EggnetMonitor" (
3 | "monster_id" INTEGER NOT NULL,
4 | "eggs_donated" INTEGER NOT NULL,
5 | "last_update" TIMESTAMP(3) NOT NULL,
6 |
7 | CONSTRAINT "EggnetMonitor_pkey" PRIMARY KEY ("monster_id")
8 | );
9 |
10 | -- CreateTable
11 | CREATE TABLE "EggnetMonitorHistory" (
12 | "id" SERIAL NOT NULL,
13 | "monster_id" INTEGER NOT NULL,
14 | "eggs_donated" INTEGER NOT NULL,
15 | "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 |
17 | CONSTRAINT "EggnetMonitorHistory_pkey" PRIMARY KEY ("id")
18 | );
19 |
20 | -- CreateIndex
21 | CREATE INDEX "EggnetMonitorHistory_monster_id_idx" ON "EggnetMonitorHistory"("monster_id");
22 |
--------------------------------------------------------------------------------
/app/database.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client-generated";
2 |
3 | export const prisma = new PrismaClient();
4 |
5 | export async function getLastUpdate() {
6 | return (
7 | (await prisma.eggnetMonitor.findFirst({ orderBy: { last_update: "desc" } }))
8 | ?.last_update || new Date(0)
9 | );
10 | }
11 |
12 | export async function getEggStatus(): Promise<{
13 | lastUpdate: Date;
14 | eggs: Record;
15 | }> {
16 | const lastUpdate = await getLastUpdate();
17 |
18 | const monsters = await prisma.eggnetMonitor.findMany({});
19 | const eggs = monsters.reduce>(
20 | (acc, r) => ({
21 | ...acc,
22 | [r.monster_id]: r.eggs_donated,
23 | }),
24 | {},
25 | );
26 |
27 | return { lastUpdate, eggs };
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*",
4 | "**/.server/**/*",
5 | "**/.client/**/*",
6 | ".react-router/types/**/*"
7 | ],
8 | "compilerOptions": {
9 | "lib": ["DOM", "DOM.Iterable", "ES2024"],
10 | "types": ["node", "vite/client"],
11 | "target": "ES2024",
12 | "module": "nodenext",
13 | "moduleResolution": "nodenext",
14 | "jsx": "react-jsx",
15 | "rootDirs": [".", "./.react-router/types"],
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./app/*"]
19 | },
20 | "plugins": [{ "name": "typescript-plugin-css-modules" }],
21 | "esModuleInterop": true,
22 | "verbatimModuleSyntax": true,
23 | "noEmit": true,
24 | "resolveJsonModule": true,
25 | "skipLibCheck": true,
26 | "strict": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/prisma/migrations/20250824153504_dedupe_history/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[monster_id,eggs_donated]` on the table `EggnetMonitorHistory` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- Delete duplicates keeping the oldest entry
8 | WITH ranked AS (
9 | SELECT
10 | id,
11 | ROW_NUMBER() OVER (
12 | PARTITION BY monster_id, eggs_donated
13 | ORDER BY "timestamp" ASC, id ASC
14 | ) AS rn
15 | FROM "public"."EggnetMonitorHistory"
16 | )
17 | DELETE FROM "public"."EggnetMonitorHistory" e
18 | USING ranked r
19 | WHERE e.id = r.id
20 | AND r.rn > 1;
21 |
22 | -- CreateIndex
23 | CREATE UNIQUE INDEX "EggnetMonitorHistory_monster_id_eggs_donated_key" ON "public"."EggnetMonitorHistory"("monster_id", "eggs_donated");
24 |
--------------------------------------------------------------------------------
/app/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-bg: #010203;
3 | --color-text: #ffffff;
4 | --color-text-secondary: #cccccc;
5 | --color-content-pane: #2d2a3b;
6 | --color-selected: rgba(104, 139, 88, 0.5);
7 | --color-tab-active: #54428e;
8 | --color-tab-active-shadow: #54428e80;
9 | --color-tab-inactive: #404040;
10 | --color-tab-inactive-shadow: #40404080;
11 | --color-warning-yes-solid: #6492cd;
12 | --color-warning-yes-transparent: #6492cd32;
13 | --color-warning-maybe-solid: #cbcd64;
14 | --color-warning-maybe-transparent: #cbcd6432;
15 | --color-warning-no-solid: #cd6464;
16 | --color-warning-no-transparent: #cd646432;
17 | }
18 |
19 | html,
20 | body,
21 | p {
22 | margin: 0;
23 | }
24 |
25 | body {
26 | background-color: var(--color-bg);
27 | color: var(--color-text);
28 | font-family: "Open Sans", sans-serif;
29 | }
30 |
31 | body img {
32 | filter: invert(1);
33 | mix-blend-mode: lighten;
34 | }
35 |
36 | a {
37 | color: var(--color-text);
38 | }
39 |
--------------------------------------------------------------------------------
/app/components/Monster.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | background-color: var(--color-warning-yes-transparent);
3 | margin: 0.5em;
4 | border-radius: 1em;
5 | overflow: hidden;
6 | }
7 |
8 | .nocopy {
9 | opacity: 0.5;
10 | }
11 |
12 | .monster {
13 | display: flex;
14 | flex-direction: row;
15 | position: relative;
16 | align-items: center;
17 | height: 3em;
18 |
19 | padding-left: 1em;
20 | padding-right: 1em;
21 | gap: 0.5em;
22 | }
23 |
24 | .monsterImage {
25 | max-height: 3em;
26 | max-width: 3em;
27 | flex-basis: 3em;
28 | }
29 |
30 | .monsterName {
31 | text-align: left;
32 | flex-grow: 1;
33 | }
34 |
35 | .monsterBadge {
36 | flex-grow: 1;
37 | text-align: right;
38 | }
39 |
40 | .expandButton {
41 | background: none;
42 | border: none;
43 | cursor: pointer;
44 | }
45 |
46 | .chartContainer {
47 | height: 0;
48 | overflow: hidden;
49 | transition: height 0.5s ease-in-out;
50 | }
51 |
52 | .chartContainer.expanded {
53 | padding: 8px 16px 16px 16px;
54 | height: 100px;
55 | }
56 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | output = "../node_modules/@prisma/client-generated"
10 | }
11 |
12 | datasource db {
13 | provider = "postgresql"
14 | url = env("DATABASE_URL")
15 | }
16 |
17 | model EggnetMonitor {
18 | monster_id Int @id
19 | eggs_donated Int
20 | last_update DateTime @updatedAt
21 | history EggnetMonitorHistory[]
22 | }
23 |
24 | model EggnetMonitorHistory {
25 | id Int @id @default(autoincrement())
26 | monster_id Int
27 | eggs_donated Int
28 | timestamp DateTime @default(now())
29 | monster EggnetMonitor @relation(fields: [monster_id], references: [monster_id])
30 |
31 | @@unique([monster_id, eggs_donated])
32 | @@index([monster_id])
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/Monsters.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { Monster, type MonsterType } from "./Monster.js";
3 | import styles from "./Monsters.module.css";
4 | import type { Sort } from "./Tabbar.js";
5 |
6 | type Props = {
7 | monsters: MonsterType[];
8 | hideCompleted: boolean;
9 | sort: Sort;
10 | };
11 |
12 | export function Monsters({ monsters, hideCompleted, sort }: Props) {
13 | const sorted = useMemo(() => {
14 | return monsters
15 | .filter((m) => !hideCompleted || m.eggs < 100)
16 | .toSorted((a, b) => {
17 | if (sort === "name") return a.name.localeCompare(b.name);
18 | if (sort === "id") return a.id - b.id;
19 | if (sort === "completion") return b.eggs - a.eggs;
20 | if (sort === "ascension") return b.priority - a.priority;
21 | return 0;
22 | });
23 | }, [monsters, sort, hideCompleted]);
24 |
25 | return (
26 |
27 | {sorted.map((m) => (
28 |
29 | ))}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/components/Tabbar.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./Tabbar.module.css";
2 | import { Button } from "./Button.js";
3 |
4 | export type Sort = "name" | "id" | "completion" | "ascension";
5 |
6 | type Props = {
7 | sort: Sort;
8 | onSort: (tab: Sort) => void;
9 | showAscensionRelevant: boolean;
10 | };
11 |
12 | export function Tabbar({ sort, onSort, showAscensionRelevant }: Props) {
13 | return (
14 |
15 |
18 |
21 |
27 | {showAscensionRelevant && (
28 |
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/components/TotalProgress.tsx:
--------------------------------------------------------------------------------
1 | import { ProgressBar } from "./ProgressBar.js";
2 | import { History } from "./History.js";
3 | import styles from "./TotalProgress.module.css";
4 |
5 | type Props = {
6 | progress: [current: number, total: number];
7 | history: { timestamp: Date; eggs_donated: number }[];
8 | };
9 |
10 | const numberFormat = new Intl.NumberFormat();
11 | const percentFormat = new Intl.NumberFormat(undefined, {
12 | style: "percent",
13 | minimumFractionDigits: 0,
14 | maximumFractionDigits: 2,
15 | });
16 |
17 | export function formatProgress([eggs, totalEggs]: [number, number]) {
18 | return `${numberFormat.format(eggs)} / ${numberFormat.format(totalEggs)} eggs donated (${percentFormat.format(eggs / totalEggs)})`;
19 | }
20 |
21 | export function TotalProgress({ progress, history }: Props) {
22 | return (
23 |
24 |
25 |
26 | {formatProgress(progress)}
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # EggNet Monitor
2 |
3 | Monitoring of the Chest Mimic familiar's DNA Lab contents in the MMORPG, Kingdom of Loathing. It was written by @Semenar and converted to TypeScript by @gausie.
4 |
5 | ## Setup
6 |
7 | ### Prerequisites
8 |
9 | - Node.js 22+
10 | - Postgres database
11 | - Kingdom of Loathing account with a Chest Mimic
12 |
13 | ### Installation
14 |
15 | 1. Install dependencies:
16 |
17 | ```bash
18 | yarn
19 | ```
20 |
21 | 2. Copy the environment template and configure:
22 |
23 | ```bash
24 | cp .env.template .env
25 | ```
26 |
27 | 3. Edit `.env` file with your credentials:
28 | - `KOL_USERNAME` and `KOL_PASSWORD` to login to the Kingdom of Loathing
29 | - `DATABASE_URL` pointing to your Postgres database
30 | - `PORT` (optional, defaults to 3000)
31 |
32 | ### Running
33 |
34 | #### Development
35 |
36 | ```bash
37 | yarn run dev
38 | ```
39 |
40 | #### Production
41 |
42 | ```bash
43 | yarn start
44 | ```
45 |
46 | ### Updating Data
47 |
48 | To fetch the latest egg donation data from Kingdom of Loathing:
49 |
50 | ```bash
51 | yarn run update
52 | ```
53 |
54 | This can be scheduled to run periodically using cron or similar.
55 |
56 | ### API Endpoints
57 |
58 | - `GET /` - Main monitor page
59 | - `GET /status` - JSON API returning egg status data
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eggnet-monitor",
3 | "version": "1.0.0",
4 | "description": "Monitoring of the Chest Mimic familiar's DNA Lab contents in the online RPG, Kingdom of Loathing",
5 | "main": "src/server.ts",
6 | "type": "module",
7 | "scripts": {
8 | "build": "react-router build",
9 | "dev": "react-router dev",
10 | "start": "react-router-serve ./build/server/index.js",
11 | "typecheck": "react-router typegen && tsc",
12 | "update": "node --env-file-if-exists=.env --import tsx app/update.ts",
13 | "format": "prettier --write ."
14 | },
15 | "dependencies": {
16 | "@prisma/client": "^6.16.2",
17 | "@react-router/node": "^7.9.1",
18 | "@react-router/serve": "^7.9.1",
19 | "clsx": "^2.1.1",
20 | "data-of-loathing": "^2.2.0",
21 | "entities": "^7.0.0",
22 | "express": "^5.1.0",
23 | "isbot": "^5.1.30",
24 | "jsdom": "^27.0.0",
25 | "kol.js": "^0.2.0",
26 | "react": "^19.1.1",
27 | "react-dom": "^19.1.1",
28 | "react-is": "^19.1.1",
29 | "react-router": "^7.9.1",
30 | "recharts": "^3.2.1",
31 | "tsx": "^4.20.5",
32 | "usehooks-ts": "^3.1.1"
33 | },
34 | "devDependencies": {
35 | "@react-router/dev": "^7.9.1",
36 | "@types/express": "^5.0.3",
37 | "@types/jsdom": "^21.1.7",
38 | "@types/node": "^24.5.2",
39 | "@types/react": "^19.1.13",
40 | "@types/react-dom": "^19.1.9",
41 | "@types/react-is": "^19.0.0",
42 | "prettier": "^3.6.2",
43 | "prisma": "^6.16.2",
44 | "typescript": "^5.9.2",
45 | "typescript-plugin-css-modules": "^5.2.0",
46 | "vite": "^7.1.6",
47 | "vite-tsconfig-paths": "^5.1.4"
48 | },
49 | "engines": {
50 | "node": ">=20.0.0"
51 | },
52 | "license": "GPL-3.0-or-later",
53 | "author": "Semenar ",
54 | "packageManager": "yarn@4.9.2"
55 | }
56 |
--------------------------------------------------------------------------------
/app/components/History.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Line,
3 | LineChart,
4 | ResponsiveContainer,
5 | Tooltip,
6 | XAxis,
7 | YAxis,
8 | } from "recharts";
9 |
10 | import styles from "./History.module.css";
11 | import { clsx } from "clsx";
12 | import { useMemo } from "react";
13 |
14 | const START = new Date("2024-01-01").getTime();
15 | const NOW = new Date().getTime();
16 | const numberFormatter = new Intl.NumberFormat();
17 |
18 | function DonationTooltip({
19 | active,
20 | payload,
21 | }: {
22 | active?: boolean;
23 | payload?: any[];
24 | }) {
25 | const isVisible = active && payload && payload.length;
26 |
27 | const data = payload?.[0]?.payload;
28 | const content = isVisible
29 | ? `${numberFormatter.format(data.eggs_donated)} @ ${new Date(data.timestamp).toLocaleString(undefined, { timeZoneName: "short" })}`
30 | : null;
31 |
32 | return (
33 |
34 | {content}
35 |
36 | );
37 | }
38 |
39 | type Props = {
40 | history: { timestamp: Date; eggs_donated: number }[];
41 | max?: number;
42 | };
43 |
44 | export function History({ history, max = 100 }: Props) {
45 | const data = useMemo(() => {
46 | return [
47 | { timestamp: START, eggs_donated: 0 },
48 | ...history.map((entry) => ({
49 | ...entry,
50 | timestamp: entry.timestamp.getTime(),
51 | })),
52 | {
53 | timestamp: NOW,
54 | eggs_donated: history.at(-1)?.eggs_donated ?? 0,
55 | },
56 | ];
57 | }, [history]);
58 |
59 | return (
60 |
61 |
62 | } />
63 |
64 |
65 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | isRouteErrorResponse,
3 | Links,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | } from "react-router";
9 |
10 | import type { Route } from "./+types/root.js";
11 | import "./index.css";
12 |
13 | export const links: Route.LinksFunction = () => [
14 | { rel: "preconnect", href: "https://fonts.googleapis.com" },
15 | {
16 | rel: "preconnect",
17 | href: "https://fonts.gstatic.com",
18 | crossOrigin: "anonymous",
19 | },
20 | {
21 | rel: "stylesheet",
22 | href: "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap",
23 | },
24 | {
25 | rel: "icon",
26 | href: "/favicon.png",
27 | },
28 | ];
29 |
30 | export function Layout({ children }: { children: React.ReactNode }) {
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {children}
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export default function App() {
49 | return ;
50 | }
51 |
52 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
53 | let message = "Oops!";
54 | let details = "An unexpected error occurred.";
55 | let stack: string | undefined;
56 |
57 | if (isRouteErrorResponse(error)) {
58 | message = error.status === 404 ? "404" : "Error";
59 | details =
60 | error.status === 404
61 | ? "The requested page could not be found."
62 | : error.statusText || details;
63 | } else if (import.meta.env.DEV && error && error instanceof Error) {
64 | details = error.message;
65 | stack = error.stack;
66 | }
67 |
68 | return (
69 |
70 | {message}
71 | {details}
72 | {stack && (
73 |
74 | {stack}
75 |
76 | )}
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/app/priorities.ts:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * 4 - Must have
4 | * 3 - Lower priority but still good
5 | * 2 - Needed for folks with missing IotMs
6 | * 1 - Nice-to-have
7 | * 0 - Unranked
8 | */
9 |
10 | export const priorities: Record = {
11 | 1185: 4, // ninja snowman assassin
12 | 66: 4, // white lion
13 | 65: 4, // whitesnake
14 | 1153: 4, // mountain man
15 | 1193: 4, // Black Crayon Frat Orc
16 | 1072: 4, // swarm of ghuol whelps
17 | 1073: 4, // big swarm of ghuol whelps
18 | 1074: 4, // giant swarm of ghuol whelps
19 | 1070: 4, // modern zmobie
20 | 1071: 4, // dirty old lihc
21 | 492: 4, // Green Ops Soldier
22 | 353: 4, // Skinflute
23 | 541: 4, // Camel's Toe
24 | 184: 4, // Astronomer
25 | 228: 4, // forest spirit
26 | 48: 4, // beanbat
27 | 460: 4, // blur
28 | 1163: 4, // Baa'baa'bu'ran
29 | 1522: 3, // red butler
30 | 1936: 3, // Witchess Knight
31 | 1942: 3, // Witchess Bishop
32 | 1939: 3, // Witchess Queen
33 | 1940: 3, // Witchess King
34 | 1941: 3, // Witchess Witch
35 | 2104: 3, // sausage goblin
36 | 1186: 3, // Black Crayon Man
37 | 1187: 3, // Black Crayon Beast
38 | 1188: 3, // Black Crayon Golem
39 | 1189: 3, // Black Crayon Undead Thing
40 | 1190: 3, // Black Crayon Manloid
41 | 1191: 3, // Black Crayon Beetle
42 | 1192: 3, // Black Crayon Hippy
43 | 1194: 3, // Black Crayon Demon
44 | 1195: 3, // Black Crayon Shambling Monstrosity
45 | 1196: 3, // Black Crayon Fish
46 | 1197: 3, // Black Crayon Goblin
47 | 1198: 3, // Black Crayon Pirate
48 | 1199: 3, // Black Crayon Flower
49 | 1200: 3, // Black Crayon Spiraling Shape
50 | 1201: 3, // Black Crayon Crimbo Elf
51 | 1202: 3, // Black Crayon Mer-kin
52 | 1203: 3, // Black Crayon Slime
53 | 1204: 3, // Black Crayon Penguin
54 | 1205: 3, // Black Crayon Elemental
55 | 1206: 3, // Black Crayon Constellation
56 | 1207: 3, // Black Crayon Hobo
57 | 1238: 3, // oil cartel
58 | 131: 3, // Racecar Bob
59 | 132: 3, // Bob Racecar
60 | 68: 3, // Knight in White Satin
61 | 1430: 3, // pygmy witch accountant
62 | 1427: 3, // pygmy janitor
63 | 547: 3, // erudite gremlin (tool)
64 | 549: 3, // batwinged gremlin (tool)
65 | 551: 3, // vegetable gremlin (tool)
66 | 553: 3, // spider gremlin (tool)
67 | 577: 2, // War Frat Mobile Grill Unit
68 | 529: 2, // lobsterfrogman
69 | 2060: 2, // fantasy bandit
70 | 1010: 2, // screambat
71 | 78: 1, // Knob Goblin Harem Girl
72 | 83: 1, // dairy goat
73 | 1526: 1, // lynyrd skinner
74 | 1559: 1, // Bram the Stoker
75 | 1432: 1, // pygmy bowler
76 | 1426: 1, // dense liana
77 | 1373: 1, // eye in the darkness
78 | 1375: 1, // slithering thing
79 | 1932: 1, // ungulith
80 | 1592: 1, // pterodactyl
81 | };
82 |
--------------------------------------------------------------------------------
/app/components/Monster.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { clsx } from "clsx";
3 | import { decodeHTML } from "entities";
4 |
5 | import { History } from "./History.js";
6 | import styles from "./Monster.module.css";
7 | import { ProgressBar } from "./ProgressBar.js";
8 |
9 | const IMAGES_SERVER = "https://d2uyhvukfffg5a.cloudfront.net";
10 | const WIKI_WEBPAGE = "https://wiki.kingdomofloathing.com";
11 |
12 | const badges = [
13 | "default",
14 | "nice-to-have",
15 | "needed for folks with missing IotMs",
16 | "lower priority but still good one",
17 | "must-have",
18 | ];
19 |
20 | export type MonsterType = {
21 | name: string;
22 | id: number;
23 | eggs: number;
24 | image: string | (string | null)[];
25 | wiki: string | null;
26 | priority: number;
27 | nocopy: boolean;
28 | history: { timestamp: Date; eggs_donated: number }[];
29 | };
30 |
31 | interface MonsterProps {
32 | monster: MonsterType;
33 | }
34 |
35 | export const Monster: React.FC = ({ monster }) => {
36 | const image = Array.isArray(monster.image) ? monster.image[0] : monster.image;
37 |
38 | const [isOpen, setIsOpen] = useState(false);
39 |
40 | const name = decodeHTML(monster.name);
41 |
42 | return (
43 |
46 |
47 |
48 |

53 |
54 |
59 | {name}
60 |
61 | {monster.eggs === 100 ? "" : ` (${monster.eggs}/100 eggs)`}
62 |
63 | {monster.nocopy && (
64 |
68 | not copyable
69 |
70 | )}
71 | {monster.priority > 0 && (
72 |
{badges[monster.priority]}
73 | )}
74 |
80 |
81 |
84 | {isOpen && }
85 |
86 |
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/app/routes/home.tsx:
--------------------------------------------------------------------------------
1 | import { createClient } from "data-of-loathing";
2 | import { useLocalStorage } from "usehooks-ts";
3 |
4 | import type { Route } from "./+types/home.js";
5 |
6 | import { priorities } from "~/priorities.js";
7 | import { getLastUpdate, prisma } from "~/database.js";
8 |
9 | import { type Sort, Tabbar } from "~/components/Tabbar.js";
10 | import { Monsters } from "~/components/Monsters.js";
11 | import { formatProgress, TotalProgress } from "~/components/TotalProgress.js";
12 | import { LastUpdate } from "~/components/LastUpdate.js";
13 | import { Footer } from "~/components/Footer.js";
14 | import { Header } from "~/components/Header.js";
15 | import { Settings } from "~/components/Settings.js";
16 | import { useMemo } from "react";
17 |
18 | const client = createClient();
19 |
20 | export async function loader() {
21 | const lastUpdate = await getLastUpdate();
22 |
23 | const monsterEggs = await prisma.eggnetMonitor.findMany({
24 | select: {
25 | eggs_donated: true,
26 | monster_id: true,
27 | history: {
28 | select: {
29 | timestamp: true,
30 | eggs_donated: true,
31 | },
32 | orderBy: {
33 | timestamp: "asc",
34 | },
35 | },
36 | },
37 | });
38 |
39 | const monsterEggsById = monsterEggs.reduce<
40 | Record
41 | >((acc, curr) => {
42 | acc[curr.monster_id] = curr;
43 | return acc;
44 | }, {});
45 |
46 | const { allMonsters } = await client.query({
47 | allMonsters: {
48 | nodes: {
49 | name: true,
50 | id: true,
51 | image: true,
52 | wiki: true,
53 | nocopy: true,
54 | },
55 | },
56 | });
57 |
58 | const monsters =
59 | allMonsters?.nodes
60 | .filter((n) => n !== null)
61 | .filter((m) => monsterEggsById[m.id] !== undefined)
62 | .map((m) => ({
63 | id: m.id,
64 | name: m.name,
65 | image: m.image,
66 | wiki: m.wiki,
67 | nocopy: m.nocopy,
68 | priority: priorities[m.id] ?? 0,
69 | eggs: monsterEggsById[m.id]?.eggs_donated ?? 0,
70 | history: monsterEggsById[m.id]?.history ?? [],
71 | })) ?? [];
72 |
73 | // Ignore nocopy monsters for progress calculation (e.g. embering hulk and infinite meat bug)
74 | const progressMonsters = monsters.filter((m) => !m.nocopy);
75 |
76 | const progress = [
77 | progressMonsters.reduce((acc, m) => acc + m.eggs, 0),
78 | progressMonsters.length * 100,
79 | ] as const;
80 |
81 | const history = await prisma.$queryRaw<
82 | {
83 | eggs_donated: number;
84 | timestamp: Date;
85 | }[]
86 | >`
87 | WITH ranked AS (
88 | SELECT
89 | id,
90 | monster_id,
91 | eggs_donated,
92 | timestamp,
93 | eggs_donated
94 | - COALESCE(
95 | LAG(eggs_donated) OVER (
96 | PARTITION BY monster_id
97 | ORDER BY timestamp ASC, id ASC
98 | ),
99 | 0
100 | ) AS delta
101 | FROM "EggnetMonitorHistory"
102 | )
103 | SELECT
104 | timestamp,
105 | CAST(
106 | SUM(delta) OVER (
107 | ORDER BY timestamp ASC, id ASC
108 | ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
109 | )
110 | AS DOUBLE PRECISION
111 | ) AS eggs_donated
112 | FROM ranked
113 | ORDER BY timestamp ASC, id ASC;
114 | `;
115 |
116 | return { lastUpdate, monsters, progress, history };
117 | }
118 |
119 | export function meta({ loaderData: { progress } }: Route.MetaArgs) {
120 | return [
121 | { title: "EggNet Monitor" },
122 | {
123 | name: "description",
124 | content: formatProgress(progress),
125 | },
126 | ];
127 | }
128 |
129 | export default function Home({
130 | loaderData: { monsters, lastUpdate, progress, history },
131 | }: Route.ComponentProps) {
132 | const [hideCompleted, setHideCompleted] = useLocalStorage(
133 | "hideCompleted",
134 | false,
135 | { initializeWithValue: false },
136 | );
137 | const [sort, setSort] = useLocalStorage("sort", "name", {
138 | initializeWithValue: false,
139 | });
140 |
141 | const showAscensionRelevant = useMemo(
142 | () => monsters.filter((m) => m.priority > 0 && m.eggs < 100).length > 0,
143 | [monsters],
144 | );
145 |
146 | return (
147 |
148 |
149 |
150 |
151 |
156 |
160 |
161 |
162 |
163 | );
164 | }
165 |
--------------------------------------------------------------------------------
/app/update.ts:
--------------------------------------------------------------------------------
1 | import { JSDOM } from "jsdom";
2 | import { Client } from "kol.js";
3 | import * as url from "node:url";
4 |
5 | import { prisma } from "./database.js";
6 | import { Prisma } from "@prisma/client-generated";
7 |
8 | async function fetchDnaLab(): Promise {
9 | const client = new Client(
10 | process.env.KOL_USERNAME!,
11 | process.env.KOL_PASSWORD!,
12 | );
13 | await client.fetchText(
14 | "place.php?whichplace=town_right&action=townright_dna",
15 | );
16 | return await client.fetchText("choice.php?forceoption=0");
17 | }
18 |
19 | async function tellOaf(monsterId: number) {
20 | if (!process.env.OAF_TOKEN) return;
21 | try {
22 | const result = await fetch(
23 | `https://oaf.loathers.net/webhooks/eggnet?token=${process.env.OAF_TOKEN}`,
24 | {
25 | method: "POST",
26 | headers: {
27 | "Content-Type": "application/json",
28 | },
29 | body: JSON.stringify({ monsterId }),
30 | },
31 | );
32 | if (!result.ok) {
33 | console.warn(
34 | "OAF webhook error",
35 | result.status,
36 | ":",
37 | result.statusText,
38 | await result.text(),
39 | );
40 | }
41 | } catch (error) {
42 | console.warn("OAF webhook error", error);
43 | }
44 | }
45 |
46 | async function updateEggStatus(
47 | monster_id: number,
48 | eggs_donated: number,
49 | ): Promise {
50 | await prisma.eggnetMonitor.upsert({
51 | where: { monster_id },
52 | update: { eggs_donated },
53 | create: {
54 | monster_id,
55 | eggs_donated,
56 | },
57 | });
58 |
59 | try {
60 | // This will fail a uniqueness constraint if we've already seen this number.
61 | // That's not a problem and will be caught and ignored.
62 | await prisma.eggnetMonitorHistory.create({
63 | data: {
64 | monster_id,
65 | eggs_donated,
66 | },
67 | });
68 |
69 | // If we got here (i.e. did not hit the uniqueness constraint)
70 | // and this is a report of 100, this monster has (probably) just been unlocked!
71 | if (eggs_donated === 100) {
72 | // If we don't have any history for this monster, don't do anything.
73 | // Maybe we are starting from an empty database for some reason
74 | if (
75 | (await prisma.eggnetMonitorHistory.count({
76 | where: { monster_id },
77 | })) <= 1
78 | )
79 | return;
80 |
81 | // Tell OAF about our discovery!
82 | await tellOaf(monster_id);
83 | }
84 | } catch (error) {
85 | if (error instanceof Prisma.PrismaClientKnownRequestError) {
86 | if (error.code === "P2002") {
87 | // We already have a record of this monster with this egg count
88 | return;
89 | }
90 | }
91 | throw error;
92 | }
93 | }
94 |
95 | async function processEggData(html: string): Promise {
96 | try {
97 | const dom = new JSDOM(html);
98 | const document = dom.window.document;
99 |
100 | const forms = document.getElementsByTagName("form");
101 | if (forms.length === 0) {
102 | throw new Error("No forms found in the page");
103 | }
104 |
105 | const form = forms[0];
106 | const options = form.getElementsByTagName("option");
107 |
108 | const updates: Array<{ monster_id: number; eggs_donated: number }> = [];
109 |
110 | for (let i = 0; i < options.length; i++) {
111 | const option = options[i];
112 | const value = option.getAttribute("value");
113 |
114 | if (!value || value === "") {
115 | continue;
116 | }
117 |
118 | const monster_id = parseInt(value, 10);
119 | if (isNaN(monster_id)) {
120 | continue;
121 | }
122 |
123 | const isCompleted = !option.hasAttribute("disabled");
124 | let eggs_donated = 100;
125 |
126 | if (!isCompleted) {
127 | const optionText = option.textContent || "";
128 | const lastBracketPos = optionText.lastIndexOf("(");
129 |
130 | if (lastBracketPos !== -1) {
131 | const bracketContent = optionText.substring(lastBracketPos);
132 | const numberMatch = bracketContent.match(/(\d+)/);
133 | if (numberMatch) {
134 | const remainingEggs = parseInt(numberMatch[1], 10);
135 | eggs_donated = 100 - remainingEggs;
136 | }
137 | }
138 | }
139 |
140 | updates.push({ monster_id, eggs_donated });
141 | }
142 |
143 | console.log(`Found ${updates.length} monsters to update`);
144 |
145 | // Update database
146 | for (const update of updates) {
147 | await updateEggStatus(update.monster_id, update.eggs_donated);
148 | }
149 |
150 | console.log("Database update completed successfully");
151 | } catch (error) {
152 | console.error("Error processing egg data:", error);
153 | throw error;
154 | }
155 | }
156 |
157 | async function runUpdate(): Promise {
158 | console.log("Starting EggNet update process...");
159 |
160 | // Validate configuration
161 | if (!process.env.KOL_USERNAME || !process.env.KOL_PASSWORD) {
162 | throw new Error(
163 | "KOL_USERNAME and KOL_PASSWORD environment variables must be set",
164 | );
165 | }
166 |
167 | // Authenticate and get data
168 | console.log("Authenticating with KoL...");
169 | const html = await fetchDnaLab();
170 |
171 | // Process the data
172 | console.log("Processing egg data...");
173 | await processEggData(html);
174 |
175 | console.log("Update process completed successfully");
176 | }
177 |
178 | // Run the update if this script is executed directly
179 | if (import.meta.url.startsWith("file:")) {
180 | const modulePath = url.fileURLToPath(import.meta.url);
181 | if (process.argv[1] === modulePath) {
182 | runUpdate();
183 | }
184 | }
185 |
--------------------------------------------------------------------------------