├── .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 | {name} 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 | --------------------------------------------------------------------------------