├── .yarnrc.yml ├── public ├── wiki.png ├── favicon.gif ├── favicon.ico ├── museum.webp ├── robots.txt └── style.css ├── app ├── routes.ts ├── context.ts ├── routes │ ├── item._index.tsx │ ├── api.plurals.ts │ ├── api.item.$id.ts │ ├── api.players.ts │ ├── api.items.ts │ ├── healthcheck.tsx │ ├── api.player.$id.ts │ ├── api.itemdata.ts │ ├── player._index.tsx │ ├── _index.tsx │ ├── about.tsx │ ├── item.$id.tsx │ ├── player.$id.missing.tsx │ └── player.$id._index.tsx ├── components │ ├── ItemName.tsx │ ├── Layout.tsx │ ├── Formerly.tsx │ ├── CustomTitle.tsx │ ├── ColourModeToggle.tsx │ ├── RankSymbol.tsx │ ├── PlayerSelect.tsx │ ├── ShowItem.tsx │ ├── ItemSelect.tsx │ ├── PlayerPageRanking.tsx │ ├── Rank.tsx │ ├── ItemDescription.tsx │ ├── RandomCollection.tsx │ ├── ItemPageRanking.tsx │ ├── CollectionInsights.tsx │ └── Typeahead.tsx ├── hooks.ts ├── middleware │ └── logging.ts ├── entry.client.tsx ├── theme.ts ├── utils.ts ├── db.server.ts ├── root.tsx ├── utils.server.ts └── entry.server.tsx ├── README.md ├── scripts ├── examples │ ├── collections.txt │ ├── README.md │ ├── player.txt │ └── items.txt ├── init.ts ├── itemsSeenFromMafia.ts ├── db.ts └── etl.ts ├── react-router.config.ts ├── vite.config.ts ├── prettier.config.mjs ├── .gitignore ├── eslint.config.mjs ├── tsconfig.json ├── package.json └── prisma └── schema.prisma /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /public/wiki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loathers/museum/main/public/wiki.png -------------------------------------------------------------------------------- /public/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loathers/museum/main/public/favicon.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loathers/museum/main/public/favicon.ico -------------------------------------------------------------------------------- /public/museum.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loathers/museum/main/public/museum.webp -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { flatRoutes } from "@react-router/fs-routes"; 2 | 3 | export default flatRoutes(); 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # museum 2 | 3 | Interactive browser for the display cases of the Kingdom of Loathing. Updated daily! 🏛️ 4 | -------------------------------------------------------------------------------- /scripts/examples/collections.txt: -------------------------------------------------------------------------------- 1 | playerid itemid quantity 2 | 59035 526 2 3 | 59035 65 1 4 | 553213 2678 1 5 | 342719 978 2 6 | 1 123 1 -------------------------------------------------------------------------------- /app/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react-router"; 2 | 3 | export const requestIdContext = createContext(null); 4 | -------------------------------------------------------------------------------- /app/routes/item._index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router"; 2 | 3 | export async function loader() { 4 | return redirect("/"); 5 | } 6 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | future: { 5 | v8_middleware: true, 6 | }, 7 | ssr: true, 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /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 | plugins: [reactRouter(), tsconfigPaths()], 7 | }); 8 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type import("prettier").Options */ 4 | export default { 5 | plugins: ["@trivago/prettier-plugin-sort-imports"], 6 | importOrder: ["", "^[~.]/"], 7 | importOrderSeparation: true, 8 | importOrderSortSpecifiers: true, 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .vscode 4 | 5 | /.cache 6 | /build 7 | /public/build 8 | .env 9 | .react-router 10 | 11 | # Yarn (not using Zero-Installs) 12 | .yarn-error.log 13 | .pnp.* 14 | .yarn/* 15 | !.yarn/patches 16 | !.yarn/plugins 17 | !.yarn/releases 18 | !.yarn/sdks 19 | !.yarn/versions -------------------------------------------------------------------------------- /scripts/examples/README.md: -------------------------------------------------------------------------------- 1 | # Data examples 2 | 3 | The data is provided to museum privately via three tab-separated text files secured behind authentication. Fro the purposes of contribution if you do not have access to this source, some example files are provided here. As of writing, `etl.ts` is not written to optionally ingest these files but it may do so in future. 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from "@eslint/js"; 3 | import react from "eslint-plugin-react"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | react.configs.flat["jsx-runtime"], 10 | { 11 | ignores: ["public/build", "build"], 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /app/components/ItemName.tsx: -------------------------------------------------------------------------------- 1 | import { itemToString } from "~/utils"; 2 | import type { SlimItem } from "~/utils.server"; 3 | 4 | type Props = { 5 | item: SlimItem; 6 | disambiguate?: boolean; 7 | plural?: boolean; 8 | }; 9 | 10 | export default function ItemName({ 11 | item, 12 | disambiguate, 13 | plural = false, 14 | }: Props) { 15 | return <>{itemToString(item, disambiguate, plural)}; 16 | } 17 | -------------------------------------------------------------------------------- /app/routes/api.plurals.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/db.server"; 2 | 3 | export async function loader() { 4 | const items = await db.item.findMany({ 5 | select: { itemid: true, name: true, plural: true }, 6 | where: { missing: false, seen: { isNot: null } }, 7 | orderBy: { itemid: "asc" }, 8 | }); 9 | 10 | return Response.json( 11 | items.map((i) => (i.plural ? i : { ...i, plural: undefined })), 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Stack } from "@chakra-ui/react"; 2 | 3 | type Props = React.PropsWithChildren<{ alignment?: "center" | "stretch" }>; 4 | 5 | export default function Layout({ children, alignment = "center" }: Props) { 6 | return ( 7 | 8 | 9 | {children} 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounce(value: T, delay: number) { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => { 8 | setDebouncedValue(value); 9 | }, delay); 10 | 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | -------------------------------------------------------------------------------- /scripts/examples/player.txt: -------------------------------------------------------------------------------- 1 | playerid name clan description 2 | 59035 HotStuff 3486 Enjoy the collection and have a nice day! 3 | \n 4 | \n. . .[[[[[[[[[[[[[ 5 | \n. .[[[ . . . . . [[[ 6 | \n. [[ . . . . . . . [[ 7 | \n.[[ . .[[[ . [[[. . [[ 8 | \n[[ . . [[[ . [[[ . . [[ 9 | \n[ . . . . . . . . . . [ 10 | \n[[ . . [ . . . [ . . [[ 11 | \n.[[ . . [[ . [[ . . [[ 12 | \n. [[ . . [[[[[ . . [[ 13 | \n. .[[[ . . . . . [[[ 14 | \n. . .[[[[[[[[[[[[[ 15 | 1 Jick 2046994343 This is my collection of flyswatter. 16 | \n 17 | \nI'm so proud. -------------------------------------------------------------------------------- /app/routes/api.item.$id.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "./+types/api.item.$id"; 2 | import { HttpError, loadCollections } from "~/utils.server"; 3 | 4 | export type LoaderReturnType = Awaited>; 5 | 6 | export async function loader({ params }: Route.LoaderArgs) { 7 | const id = Number(params.id); 8 | 9 | try { 10 | return Response.json(await loadCollections(id, 10)); 11 | } catch (error) { 12 | if (error instanceof HttpError) throw error.toRouteError(); 13 | throw error; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /scripts/init.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CREATE_COLLECTION_TABLE, 3 | CREATE_DAILY_COLLECTION_TABLE, 4 | CREATE_ITEM_SEEN_TABLE, 5 | CREATE_ITEM_TABLE, 6 | CREATE_PLAYER_NAME_CHANGE_TABLE, 7 | CREATE_PLAYER_TABLE, 8 | CREATE_SETTING_TABLE, 9 | sql, 10 | } from "./db"; 11 | 12 | await sql.unsafe(CREATE_SETTING_TABLE); 13 | await sql.unsafe(CREATE_ITEM_TABLE); 14 | await sql.unsafe(CREATE_ITEM_SEEN_TABLE); 15 | await sql.unsafe(CREATE_PLAYER_TABLE); 16 | await sql.unsafe(CREATE_PLAYER_NAME_CHANGE_TABLE); 17 | await sql.unsafe(CREATE_COLLECTION_TABLE); 18 | await sql.unsafe(CREATE_DAILY_COLLECTION_TABLE); 19 | process.exit(0); 20 | -------------------------------------------------------------------------------- /app/components/Formerly.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | 3 | type Props = { 4 | names: { oldname: string; when: Date }[]; 5 | }; 6 | 7 | export default function Formerly({ names }: Props) { 8 | if (names.length === 0) return null; 9 | 10 | return ( 11 |
12 | formerly{" "} 13 | {names.map((n, i) => ( 14 | 15 | {n.oldname} 16 | {i < names.length - 2 ? ", " : i < names.length - 1 ? " and " : ""} 17 | 18 | ))} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/routes/api.players.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "./+types/api.players"; 2 | import { db } from "~/db.server"; 3 | 4 | export async function loader({ request }: Route.LoaderArgs) { 5 | const url = new URL(request.url); 6 | 7 | const q = url.searchParams.get("q"); 8 | 9 | if (!q) return Response.json([]); 10 | 11 | const players = await db.player.findMany({ 12 | where: { 13 | name: { 14 | contains: q, 15 | mode: "insensitive", 16 | }, 17 | }, 18 | select: { 19 | name: true, 20 | playerid: true, 21 | }, 22 | orderBy: [{ name: "asc" }], 23 | }); 24 | 25 | return Response.json(players); 26 | } 27 | -------------------------------------------------------------------------------- /app/components/CustomTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@chakra-ui/react"; 2 | 3 | import { HOLDER_ID } from "~/utils"; 4 | 5 | type Props = { 6 | player: { name: string; playerid: number }; 7 | }; 8 | 9 | const titlesById: Map = new Map([ 10 | [845708, "RIP 1967-2025"], 11 | [HOLDER_ID, "(allowed to add quest items to display case)"], 12 | ]); 13 | 14 | export default function CustomTitle({ player }: Props) { 15 | if (!titlesById.has(player.playerid)) return null; 16 | 17 | return ( 18 | 19 | {titlesById.get(player.playerid)} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/middleware/logging.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "../+types/root"; 2 | import { requestIdContext } from "../context"; 3 | 4 | export const loggingMiddleware: Route.MiddlewareFunction = async ( 5 | { request, context }, 6 | next, 7 | ) => { 8 | const requestId = crypto.randomUUID(); 9 | context.set(requestIdContext, requestId); 10 | 11 | console.log( 12 | `[${requestId}] ${request.method} ${request.url} (${request.headers.get("User-Agent")})`, 13 | ); 14 | 15 | const start = performance.now(); 16 | const response = await next(); 17 | const duration = performance.now() - start; 18 | 19 | console.log(`[${requestId}] Response ${response.status} (${duration}ms)`); 20 | 21 | return response; 22 | }; 23 | -------------------------------------------------------------------------------- /scripts/examples/items.txt: -------------------------------------------------------------------------------- 1 | itemid name picture descid description type itemclass candiscard cantransfer quest gift smith cook cocktail jewelry hands multiuse sellvalue power quest mrstore plural 2 | 1 seal-clubbing club club 868780591 This is a club used to club seals. You could probably club other things with it, too. Just make sure to regularly seal your seal-clubbing club with seal-clubbing club seal. weapon club 1 1 0 0 1 0 0 0 1 0 1 10 0 0 3 | 2 seal tooth tooth 617818041 This is the tooth from a baby seal. It's like a smaller version of the tooth from an adult seal. It's surprisingly sharp, unless you aren't surprised by things that are really sharp, in which case it's sharp, but not surprisingly so. useboth 1 1 0 0 1 0 0 0 1 0 1 0 0 0 seal teeth -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".react-router/types/**/*", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "jsx": "react-jsx", 10 | "lib": ["DOM", "DOM.Iterable", "ES2024"], 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "noEmit": true, 14 | "paths": { 15 | "~/*": ["./app/*"] 16 | }, 17 | "resolveJsonModule": true, 18 | "rootDirs": [".", "./.react-router/types"], 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "target": "ES2019", 22 | "types": ["@react-router/node", "vite/client"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/routes/api.items.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "./+types/api.items"; 2 | import { db } from "~/db.server"; 3 | 4 | export async function loader({ request }: Route.LoaderArgs) { 5 | const url = new URL(request.url); 6 | 7 | const q = url.searchParams.get("q"); 8 | 9 | if (!q) return Response.json([]); 10 | 11 | const items = await db.item.findMany({ 12 | where: { 13 | missing: false, 14 | name: { 15 | contains: q, 16 | mode: "insensitive", 17 | }, 18 | seen: { isNot: null }, 19 | }, 20 | select: { 21 | name: true, 22 | itemid: true, 23 | ambiguous: true, 24 | }, 25 | orderBy: [{ name: "asc" }, { itemid: "asc" }], 26 | }); 27 | 28 | return Response.json(items); 29 | } 30 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import createEmotionCache from "@emotion/cache"; 2 | import { CacheProvider } from "@emotion/react"; 3 | import { StrictMode, startTransition } from "react"; 4 | import { hydrateRoot } from "react-dom/client"; 5 | import { HydratedRouter } from "react-router/dom"; 6 | 7 | function hydrate() { 8 | const emotionCache = createEmotionCache({ key: "css" }); 9 | 10 | startTransition(() => { 11 | hydrateRoot( 12 | document, 13 | 14 | 15 | 16 | 17 | , 18 | ); 19 | }); 20 | } 21 | 22 | if (typeof requestIdleCallback === "function") { 23 | requestIdleCallback(hydrate); 24 | } else { 25 | // Safari doesn't support requestIdleCallback 26 | // https://caniuse.com/requestidlecallback 27 | setTimeout(hydrate, 1); 28 | } 29 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderFunctionArgs } from "react-router"; 2 | 3 | import { db } from "~/db.server"; 4 | 5 | export const loader = async ({ request }: LoaderFunctionArgs) => { 6 | const host = 7 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 8 | 9 | try { 10 | const url = new URL("/", `http://${host}`); 11 | // if we can connect to the database and make a simple query 12 | // and make a HEAD request to ourselves, then we're good. 13 | await Promise.all([ 14 | db.player.count(), 15 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 16 | if (!r.ok) return Promise.reject(r); 17 | }), 18 | ]); 19 | return new Response("OK"); 20 | } catch (error: unknown) { 21 | console.log("healthcheck ❌", { error }); 22 | return new Response("ERROR", { status: 500 }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /item 3 | Disallow: /player 4 | 5 | User-agent: GPTBot 6 | Allow: / 7 | 8 | User-agent: Google-Extended 9 | Disallow: / 10 | 11 | User-agent: Applebot-Extended 12 | Disallow: / 13 | 14 | User-agent: Applebot 15 | Allow: / 16 | 17 | User-agent: anthropic-ai 18 | Disallow: / 19 | 20 | User-agent: Bytespider 21 | Disallow: / 22 | 23 | User-agent: CCBot 24 | Disallow: / 25 | 26 | User-agent: ChatGPT-User 27 | Disallow: / 28 | 29 | User-agent: ClaudeBot 30 | Disallow: / 31 | 32 | User-agent: Claude-Web 33 | Disallow: / 34 | 35 | User-agent: Diffbot 36 | Disallow: / 37 | 38 | User-agent: FacebookBot 39 | Disallow: / 40 | 41 | User-agent: ImagesiftBot 42 | Disallow: / 43 | 44 | User-agent: Omgilibot 45 | Disallow: / 46 | 47 | User-agent: Omgili 48 | Disallow: / 49 | 50 | User-agent: PerplexityBot 51 | Disallow: / 52 | 53 | User-agent: YouBot 54 | Disallow: / 55 | -------------------------------------------------------------------------------- /scripts/itemsSeenFromMafia.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "data-of-loathing"; 2 | 3 | import { sql } from "./db"; 4 | 5 | const client = createClient(); 6 | 7 | const knownItems = await client.query({ 8 | allItems: { 9 | nodes: { 10 | id: true, 11 | }, 12 | }, 13 | }); 14 | 15 | const items = (knownItems.allItems?.nodes ?? []) 16 | .filter((item) => item !== null) 17 | .map((item) => ({ itemid: item.id })); 18 | 19 | let inserted = 0; 20 | 21 | const CHUNK = 1000; 22 | 23 | for (let i = 0; i < items.length; i += CHUNK) { 24 | const { count } = 25 | await sql`INSERT INTO "ItemSeen" ${sql(items.slice(i, i + CHUNK), "itemid")} ON CONFLICT DO NOTHING`; 26 | inserted += count; 27 | } 28 | 29 | console.log( 30 | `Inserted ${inserted} items about which mafia knows (this includes ignored inserts for items mafia knows about that the Item table doesn't)`, 31 | ); 32 | process.exit(); 33 | -------------------------------------------------------------------------------- /app/theme.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SystemStyleObject, 3 | createSystem, 4 | defaultConfig, 5 | defineRecipe, 6 | defineSemanticTokens, 7 | } from "@chakra-ui/react"; 8 | 9 | const linkRecipe = defineRecipe<{ 10 | variant: { underline: SystemStyleObject; plain: SystemStyleObject }; 11 | }>({ 12 | defaultVariants: { 13 | variant: "underline", 14 | }, 15 | }); 16 | 17 | const semanticTokens = defineSemanticTokens({ 18 | colors: { 19 | goldmedal: { 20 | value: { base: "#fad25a", _dark: "#7d6a36" }, 21 | }, 22 | silvermedal: { 23 | value: { base: "#cbcace", _dark: "#676668" }, 24 | }, 25 | bronzemedal: { 26 | value: { base: "#cea972", _dark: "#695840" }, 27 | }, 28 | }, 29 | }); 30 | 31 | export const theme = createSystem(defaultConfig, { 32 | theme: { 33 | semanticTokens, 34 | recipes: { 35 | link: linkRecipe, 36 | }, 37 | }, 38 | }); 39 | 40 | export default theme; 41 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | import { decodeHTML } from "entities"; 2 | 3 | export const HOLDER_ID = 216194; 4 | 5 | export const pluralise = (item: { plural?: string | null; name: string }) => { 6 | return item.plural || item.name + "s"; 7 | }; 8 | 9 | export function englishJoin(elements: React.ReactNode[]) { 10 | if (elements.length === 0) return null; 11 | if (elements.length === 1) return elements[0]; 12 | return elements.map((el, i) => [ 13 | i === 0 ? null : i === elements.length - 1 ? " and " : ", ", 14 | el, 15 | ]); 16 | } 17 | 18 | export type SlimItem = { itemid: number; name: string; ambiguous: boolean }; 19 | 20 | export function itemToString( 21 | item: SlimItem | null | undefined, 22 | disambiguate = false, 23 | usePlural = false, 24 | ) { 25 | return item 26 | ? `${item.ambiguous && disambiguate ? `[${item.itemid}]` : ""}${decodeHTML( 27 | usePlural ? pluralise(item) : item.name, 28 | ).replace(/(<([^>]+)>)/gi, "")}` 29 | : ""; 30 | } 31 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | to { 6 | transform: rotate(360deg); 7 | } 8 | } 9 | 10 | @keyframes rainbow { 11 | 0% { 12 | background-image: linear-gradient( 13 | 270deg, 14 | red, 15 | purple, 16 | brown, 17 | black, 18 | magenta, 19 | teal 20 | ); 21 | } 22 | 25% { 23 | background-image: linear-gradient( 24 | 270deg, 25 | purple, 26 | brown, 27 | black, 28 | magenta, 29 | teal, 30 | red 31 | ); 32 | } 33 | 50% { 34 | background-image: linear-gradient( 35 | 270deg, 36 | brown, 37 | black, 38 | magenta, 39 | teal, 40 | purple, 41 | red 42 | ); 43 | } 44 | 100% { 45 | background-image: linear-gradient( 46 | 270deg, 47 | magenta, 48 | teal, 49 | black, 50 | brown, 51 | purple, 52 | red 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/routes/api.player.$id.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "./+types/api.player.$id"; 2 | import { db } from "~/db.server"; 3 | import { HttpError } from "~/utils.server"; 4 | 5 | export async function loader({ params }: Route.LoaderArgs) { 6 | const id = Number(params.id); 7 | 8 | const player = await db.player.findUnique({ 9 | where: { playerid: id }, 10 | select: { 11 | playerid: true, 12 | name: true, 13 | collections: { 14 | select: { 15 | quantity: true, 16 | rank: true, 17 | itemid: true, 18 | }, 19 | orderBy: { itemid: "asc" }, 20 | }, 21 | nameChanges: { 22 | select: { 23 | oldname: true, 24 | when: true, 25 | }, 26 | orderBy: { when: "desc" }, 27 | }, 28 | }, 29 | }); 30 | 31 | if (!player) 32 | return Response.json( 33 | new HttpError(404, "Player not found with that id").toRouteError(), 34 | { status: 404 }, 35 | ); 36 | 37 | return Response.json(player); 38 | } 39 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var globalPrisma: PrismaClient; 5 | } 6 | 7 | let prisma: PrismaClient; 8 | 9 | if (process.env.NODE_ENV === "production") { 10 | prisma = new PrismaClient(); 11 | prisma.$connect(); 12 | } else { 13 | if (!global.globalPrisma) { 14 | global.globalPrisma = new PrismaClient({ 15 | log: [ 16 | { 17 | emit: "event", 18 | level: "query", 19 | }, 20 | ], 21 | }); 22 | } 23 | prisma = global.globalPrisma; 24 | 25 | prisma.$on("query", async (e) => { 26 | console.log(`${e.query} ${e.params}`); 27 | }); 28 | } 29 | 30 | export const db = prisma; 31 | 32 | export async function getMaxAge() { 33 | const { value } = 34 | (await prisma.setting.findFirst({ 35 | where: { key: "nextUpdate" }, 36 | })) ?? {}; 37 | if (!value) return 1800; 38 | 39 | const secondsLeft = Math.ceil((Number(value) - Date.now()) / 1000); 40 | return Math.max(0, secondsLeft); 41 | } 42 | -------------------------------------------------------------------------------- /app/components/ColourModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@chakra-ui/react"; 2 | import { useTheme } from "next-themes"; 3 | import { useEffect, useState } from "react"; 4 | import { LuMoon, LuSun } from "react-icons/lu"; 5 | 6 | function useColorMode() { 7 | const { resolvedTheme, setTheme } = useTheme(); 8 | const toggleColorMode = () => { 9 | setTheme(resolvedTheme === "light" ? "dark" : "light"); 10 | }; 11 | return { 12 | colorMode: resolvedTheme, 13 | setColorMode: setTheme, 14 | toggleColorMode, 15 | }; 16 | } 17 | 18 | export function ColourModeToggle() { 19 | const [mounted, setMounted] = useState(false); 20 | const { toggleColorMode, colorMode } = useColorMode(); 21 | 22 | useEffect(() => setMounted(true), []); 23 | 24 | if (!mounted) return ; 25 | 26 | return ( 27 | 33 | {colorMode === "light" ? : } 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/components/RankSymbol.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | type Props = { 4 | rank: number; 5 | joint?: boolean; 6 | }; 7 | 8 | const getRankSymbol = (rank: number) => { 9 | switch (rank) { 10 | case 1: 11 | return "🥇"; 12 | case 2: 13 | return "🥈"; 14 | case 3: 15 | return "🥉"; 16 | default: 17 | return `#${rank}`; 18 | } 19 | }; 20 | 21 | const numberSuffix = (number: number) => { 22 | if (number > 3 && number < 21) return "th"; 23 | switch (number % 10) { 24 | case 1: 25 | return "st"; 26 | case 2: 27 | return "nd"; 28 | case 3: 29 | return "rd"; 30 | default: 31 | return "th"; 32 | } 33 | }; 34 | 35 | export default function RankSymbol({ rank, joint }: Props) { 36 | const style = useMemo( 37 | () => ({ 38 | display: "inline", 39 | textShadow: 40 | rank <= 3 41 | ? "-2px -2px 0 white, 2px -2px 0 white, -2px 2px white, 2px 2px white" 42 | : undefined, 43 | cursor: "default", 44 | }), 45 | [rank], 46 | ); 47 | 48 | return ( 49 | 53 | {getRankSymbol(rank)} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/routes/api.itemdata.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/db.server"; 2 | 3 | export async function loader() { 4 | const items = await db.item.findMany({ 5 | select: { 6 | itemid: true, 7 | name: true, 8 | picture: true, 9 | descid: true, 10 | type: true, 11 | itemclass: true, 12 | candiscard: true, 13 | cantransfer: true, 14 | quest: true, 15 | gift: true, 16 | smith: true, 17 | cook: true, 18 | cocktail: true, 19 | jewelry: true, 20 | multiuse: true, 21 | sellvalue: true, 22 | power: true, 23 | plural: true, 24 | }, 25 | where: { missing: false, seen: { isNot: null } }, 26 | orderBy: { itemid: "asc" }, 27 | }); 28 | 29 | return Response.json( 30 | items.map((i) => ({ 31 | id: i.itemid, 32 | name: i.name, 33 | descid: i.descid, 34 | image: i.picture, 35 | type: i.type, 36 | itemclass: i.itemclass, 37 | power: i.power, 38 | multiple: i.multiuse, 39 | smith: i.smith, 40 | cook: i.cook, 41 | mix: i.cocktail, 42 | jewelry: i.jewelry, 43 | d: i.candiscard, 44 | t: i.cantransfer, 45 | q: i.quest, 46 | g: i.gift, 47 | autosell: i.sellvalue, 48 | plural: i.plural ?? undefined, 49 | })), 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/components/PlayerSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { Player } from "@prisma/client"; 2 | import { useEffect, useState } from "react"; 3 | import { useFetcher } from "react-router"; 4 | 5 | import Typeahead from "~/components/Typeahead"; 6 | import { useDebounce } from "~/hooks"; 7 | 8 | type Props = { 9 | label: string; 10 | onChange?: (player: Player | null) => unknown; 11 | loading?: boolean; 12 | }; 13 | 14 | export const comboboxStyles = { display: "inline-block", marginLeft: "5px" }; 15 | 16 | const playerToString = (player: Player | null) => player?.name ?? ""; 17 | 18 | export default function ItemSelect({ label, onChange, loading }: Props) { 19 | const { load, ...fetcher } = useFetcher(); 20 | 21 | const [query, setQuery] = useState(undefined); 22 | const debouncedQuery = useDebounce(query, 300); 23 | 24 | useEffect(() => { 25 | if (!debouncedQuery) return; 26 | load(`/api/players?q=${debouncedQuery}`); 27 | }, [debouncedQuery, load]); 28 | 29 | return ( 30 | 31 | label={label} 32 | items={(fetcher.data as Player[]) ?? []} 33 | onChange={onChange} 34 | onInputChange={setQuery} 35 | itemToString={playerToString} 36 | loading={loading || fetcher.state !== "idle"} 37 | renderItem={playerToString} 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/components/ShowItem.tsx: -------------------------------------------------------------------------------- 1 | import { Center } from "@chakra-ui/react"; 2 | import { useEffect } from "react"; 3 | import { Link, useFetcher } from "react-router"; 4 | 5 | import ItemName from "~/components/ItemName"; 6 | import { type LoaderReturnType } from "~/routes/api.item.$id"; 7 | import { itemToString } from "~/utils"; 8 | 9 | type Props = { itemid: number }; 10 | 11 | export default function ShowItem({ itemid }: Props) { 12 | const fetcher = useFetcher(); 13 | 14 | useEffect(() => { 15 | if (fetcher.state === "idle" && !fetcher.data) { 16 | fetcher.load(`/api/item/${itemid}`); 17 | } 18 | }, [itemid, fetcher]); 19 | 20 | if (!fetcher.data) { 21 | return <>Loading...; 22 | } 23 | 24 | const item = fetcher.data; 25 | 26 | const OptionalLink = ({ children }: React.PropsWithChildren) => 27 | item.collections.length > 0 ? ( 28 | {children} 29 | ) : ( 30 | <>{children} 31 | ); 32 | 33 | return ( 34 | 35 |
36 | {itemToString(item)} 40 |
41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/components/ItemSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useFetcher } from "react-router"; 3 | 4 | import ItemName from "~/components/ItemName"; 5 | import Typeahead from "~/components/Typeahead"; 6 | import { useDebounce } from "~/hooks"; 7 | import { itemToString } from "~/utils"; 8 | import type { SlimItem } from "~/utils.server"; 9 | 10 | type Props = { 11 | label: string; 12 | onChange?: (item?: SlimItem | null) => unknown; 13 | loading?: boolean; 14 | }; 15 | 16 | export const comboboxStyles = { display: "inline-block", marginLeft: "5px" }; 17 | 18 | export default function ItemSelect({ label, onChange, loading }: Props) { 19 | const { load, ...fetcher } = useFetcher(); 20 | 21 | const [query, setQuery] = useState(undefined); 22 | const debouncedQuery = useDebounce(query, 300); 23 | 24 | useEffect(() => { 25 | if (!debouncedQuery) return; 26 | load(`/api/items?q=${debouncedQuery}`); 27 | }, [debouncedQuery, load]); 28 | 29 | return ( 30 | 31 | label={label} 32 | items={(fetcher.data as SlimItem[]) ?? []} 33 | onChange={onChange} 34 | onInputChange={setQuery} 35 | itemToString={itemToString} 36 | loading={loading || fetcher.state !== "idle"} 37 | renderItem={(item) => } 38 | /> 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/routes/player._index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Group, Heading, Stack } from "@chakra-ui/react"; 2 | import { useCallback, useState } from "react"; 3 | import { LuArrowLeft } from "react-icons/lu"; 4 | import { type MetaFunction, Link as RRLink, useNavigate } from "react-router"; 5 | 6 | import Layout from "~/components/Layout"; 7 | import PlayerSelect from "~/components/PlayerSelect"; 8 | 9 | export const meta: MetaFunction = () => [{ title: `Museum :: Players` }]; 10 | 11 | export default function PlayerRoot() { 12 | const navigate = useNavigate(); 13 | const [loading, setLoading] = useState(false); 14 | 15 | const browsePlayer = useCallback( 16 | (player: { playerid: number } | null) => { 17 | if (!player) return; 18 | setLoading(true); 19 | navigate(`/player/${player.playerid}`); 20 | }, 21 | [navigate], 22 | ); 23 | 24 | return ( 25 | 26 | 27 | 28 | Players 29 | 30 | 31 | 37 | 38 | 39 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/components/PlayerPageRanking.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Table } from "@chakra-ui/react"; 2 | import type { Item } from "@prisma/client"; 3 | import { Link as RRLink } from "react-router"; 4 | 5 | import ItemName from "./ItemName"; 6 | import Rank from "./Rank"; 7 | 8 | export type Collection = { 9 | quantity: number; 10 | rank: number; 11 | item: Item; 12 | }; 13 | 14 | type Props = { 15 | collections: Collection[]; 16 | }; 17 | 18 | export default function ItemPageRanking({ collections }: Props) { 19 | return ( 20 | 21 | 22 | 23 | 24 | Rank 25 | Item 26 | Quantity 27 | 28 | 29 | 30 | {collections.map(({ item, rank, quantity }) => ( 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ))} 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/components/Rank.tsx: -------------------------------------------------------------------------------- 1 | import { Table, Text } from "@chakra-ui/react"; 2 | 3 | import RankSymbol from "./RankSymbol"; 4 | 5 | type Props = { 6 | rank: number; 7 | quantity: number; 8 | difference?: number; 9 | joint: boolean; 10 | children: React.ReactNode; 11 | }; 12 | 13 | function bg(rank: number) { 14 | switch (rank) { 15 | case 1: 16 | return "goldmedal"; 17 | case 2: 18 | return "silvermedal"; 19 | case 3: 20 | return "bronzemedal"; 21 | default: 22 | return "transparent"; 23 | } 24 | } 25 | 26 | export default function Rank({ 27 | rank, 28 | joint, 29 | quantity, 30 | children, 31 | difference, 32 | }: Props) { 33 | return ( 34 | 35 | 36 | 37 | 38 | {children} 39 | 40 | {quantity.toLocaleString()} 41 | 42 | {difference !== undefined && ( 43 | 44 | {difference > 0 && ( 45 | 55 | ⤴️ 56 | 57 | )} 58 | 59 | )} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /app/components/ItemDescription.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Box, Stack } from "@chakra-ui/react"; 2 | 3 | import ShowItem from "./ShowItem"; 4 | 5 | function DescriptionParagraph({ 6 | value, 7 | spacing, 8 | }: { 9 | value: string; 10 | spacing: number; 11 | }) { 12 | return ( 13 | 17 | ); 18 | } 19 | 20 | function DescriptionMacro({ type, value }: { type: string; value: number }) { 21 | const contents = (() => { 22 | switch (type) { 23 | case "showitem": 24 | return ; 25 | default: 26 | return `${type}:${value}`; 27 | } 28 | })(); 29 | return ( 30 | 31 | {contents} 32 | 33 | ); 34 | } 35 | 36 | type Props = { 37 | description: string | null; 38 | spacing?: number; 39 | }; 40 | 41 | export default function ItemDescription({ description, spacing = 2 }: Props) { 42 | const contents = ("

" + (description || "")) 43 | .replace(/\\[rn]/g, "") 44 | .split(/(showitem): ?(\d+)/) 45 | .map((value, i, arr) => { 46 | switch (i % 3) { 47 | case 0: 48 | return ( 49 | 50 | ); 51 | case 1: 52 | return ( 53 | 54 | ); 55 | default: 56 | return null; 57 | } 58 | }); 59 | 60 | return ( 61 | 62 | 63 | {contents} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/components/RandomCollection.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Link, Spinner, Text, type TextProps } from "@chakra-ui/react"; 2 | import type { DailyCollection, Player } from "@prisma/client"; 3 | import { decodeHTML } from "entities"; 4 | import { useEffect, useState } from "react"; 5 | import { Link as RRLink } from "react-router"; 6 | 7 | import { englishJoin, pluralise } from "~/utils"; 8 | 9 | type Props = { 10 | collections: DailyCollection[]; 11 | }; 12 | 13 | const Highlighted = (props: TextProps) => ; 14 | 15 | export default function RandomCollection({ collections }: Props) { 16 | const [collection, setCollection] = useState(null); 17 | useEffect( 18 | () => 19 | setCollection( 20 | collections[Math.floor(Math.random() * collections.length)], 21 | ), 22 | [collections], 23 | ); 24 | 25 | if (!collection) return ; 26 | 27 | const { itemid, name, plural } = collection; 28 | const players = collection.players as Pick[]; 29 | return ( 30 | 31 | For example, you can see how{" "} 32 | {englishJoin( 33 | players.map((p) => ( 34 | 35 | 36 | {p.name} 37 | 38 | 39 | )), 40 | )}{" "} 41 | {players.length === 1 ? "has" : "jointly have"} the most{" "} 42 | 43 | 44 | 49 | 50 | 51 | . 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from "@chakra-ui/react"; 2 | import { ThemeProvider } from "next-themes"; 3 | import { 4 | Links, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | data, 10 | } from "react-router"; 11 | 12 | import { type Route } from "./+types/root"; 13 | import { getMaxAge } from "./db.server"; 14 | import { loggingMiddleware } from "./middleware/logging"; 15 | import { theme } from "./theme"; 16 | 17 | export const meta: Route.MetaFunction = () => [{ title: "Museum" }]; 18 | 19 | export const links: Route.LinksFunction = () => [ 20 | { 21 | rel: "stylesheet", 22 | href: "/style.css", 23 | }, 24 | ]; 25 | 26 | export const loader = async () => { 27 | return data( 28 | {}, 29 | { 30 | headers: { 31 | "Cache-Control": `public, max-age=${await getMaxAge()}`, 32 | }, 33 | }, 34 | ); 35 | }; 36 | 37 | export const middleware: Route.MiddlewareFunction[] = [loggingMiddleware]; 38 | 39 | export const headers: Route.HeadersFunction = ({ loaderHeaders }) => { 40 | return loaderHeaders; 41 | }; 42 | 43 | export function Document({ children }: React.PropsWithChildren) { 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {children} 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | export default function App() { 62 | return ( 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /app/utils.server.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db.server"; 2 | 3 | export type SlimItem = { itemid: number; name: string; ambiguous: boolean }; 4 | 5 | export class HttpError { 6 | message: string; 7 | status: number; 8 | 9 | constructor(status: number, message: string) { 10 | this.status = status; 11 | this.message = message; 12 | } 13 | 14 | toRouteError() { 15 | return Response.json( 16 | { status: this.status, message: this.message }, 17 | { 18 | status: this.status, 19 | statusText: HTTP_ERROR_TYPES[this.status] || "Unknown Error", 20 | }, 21 | ); 22 | } 23 | } 24 | 25 | export const ITEM_NOT_FOUND_ERROR = new HttpError( 26 | 404, 27 | "That item, if it exists at all, has no collections.", 28 | ); 29 | 30 | const HTTP_ERROR_TYPES: { [key: number]: string } = { 31 | 404: "Not Found", 32 | 400: "Bad Request", 33 | 500: "Internal Server Error", 34 | }; 35 | 36 | export async function loadCollections(id: number, take = 999) { 37 | if (!id) throw new HttpError(400, "An item id must be specified"); 38 | if (id >= 2 ** 31) throw ITEM_NOT_FOUND_ERROR; 39 | 40 | const item = await db.item.findFirst({ 41 | where: { 42 | itemid: id, 43 | seen: { isNot: null }, 44 | missing: false, 45 | }, 46 | select: { 47 | name: true, 48 | description: true, 49 | picture: true, 50 | itemid: true, 51 | ambiguous: true, 52 | collections: { 53 | select: { 54 | quantity: true, 55 | rank: true, 56 | player: { 57 | select: { 58 | playerid: true, 59 | name: true, 60 | }, 61 | }, 62 | }, 63 | orderBy: [{ rank: "asc" }, { player: { name: "asc" } }], 64 | take, 65 | }, 66 | }, 67 | }); 68 | 69 | if (!item) { 70 | throw ITEM_NOT_FOUND_ERROR; 71 | } 72 | 73 | return item; 74 | } 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "react-router build", 6 | "dev": "react-router dev", 7 | "start": "react-router-serve ./build/server/index.js", 8 | "typecheck": "react-router typegen && tsc", 9 | "format": "prettier --write . && prisma format", 10 | "etl": "node --env-file-if-exists=.env --import tsx scripts/etl.ts", 11 | "db-init": "node --env-file-if-exists=.env --import tsx scripts/init.ts", 12 | "items-seen-from-mafia": "node --env-file-if-exists=.env --import tsx scripts/itemsSeenFromMafia.ts" 13 | }, 14 | "type": "module", 15 | "dependencies": { 16 | "@chakra-ui/react": "^3.30.0", 17 | "@chakra-ui/system": "^2.6.2", 18 | "@emotion/cache": "^11.14.0", 19 | "@emotion/react": "^11.14.0", 20 | "@emotion/server": "^11.11.0", 21 | "@emotion/styled": "^11.14.1", 22 | "@prisma/client": "^6.19.1", 23 | "@react-router/fs-routes": "^7.11.0", 24 | "@react-router/node": "^7.11.0", 25 | "downshift": "^9.0.13", 26 | "entities": "^7.0.0", 27 | "isbot": "^5.1.32", 28 | "next-themes": "^0.4.6", 29 | "node-fetch": "^3.3.2", 30 | "postgres": "^3.4.7", 31 | "prisma": "^6.19.1", 32 | "react": "19.2.3", 33 | "react-dom": "19.2.3", 34 | "react-icons": "^5.5.0", 35 | "react-router": "^7.11.0", 36 | "typescript": "^5.9.3" 37 | }, 38 | "devDependencies": { 39 | "@eslint/js": "^9.39.2", 40 | "@react-router/dev": "^7.11.0", 41 | "@react-router/serve": "^7.11.0", 42 | "@trivago/prettier-plugin-sort-imports": "^6.0.0", 43 | "@types/node": "^25.0.3", 44 | "@types/react": "^19.2.7", 45 | "@types/react-dom": "^19.2.3", 46 | "data-of-loathing": "^2.6.2", 47 | "eslint": "^9.39.2", 48 | "eslint-plugin-react": "^7.37.5", 49 | "prettier": "^3.7.4", 50 | "tsx": "^4.21.0", 51 | "typescript": "^5.9.3", 52 | "typescript-eslint": "^8.50.0", 53 | "vite": "^7.3.0", 54 | "vite-tsconfig-paths": "^6.0.3" 55 | }, 56 | "resolutions": { 57 | "@types/react": "npm:types-react@rc", 58 | "@types/react-dom": "npm:types-react-dom@rc", 59 | "@emotion/utils": "1.4.0" 60 | }, 61 | "engines": { 62 | "node": ">=20" 63 | }, 64 | "packageManager": "yarn@4.2.1" 65 | } 66 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Group, 4 | Heading, 5 | Image, 6 | Spinner, 7 | Stack, 8 | } from "@chakra-ui/react"; 9 | import { Suspense, useCallback, useState } from "react"; 10 | import { LuInfo, LuSearch } from "react-icons/lu"; 11 | import { 12 | Await, 13 | type LinksFunction, 14 | type MetaFunction, 15 | Link as RRLink, 16 | useLoaderData, 17 | useNavigate, 18 | } from "react-router"; 19 | 20 | import { ColourModeToggle } from "~/components/ColourModeToggle"; 21 | import ItemSelect from "~/components/ItemSelect"; 22 | import Layout from "~/components/Layout"; 23 | import RandomCollection from "~/components/RandomCollection"; 24 | import { db } from "~/db.server"; 25 | 26 | export const loader = async () => { 27 | return { 28 | collections: await db.dailyCollection.findMany({}), 29 | }; 30 | }; 31 | 32 | export const links: LinksFunction = () => [ 33 | { 34 | rel: "icon", 35 | href: "/favicon.gif", 36 | type: "image/gif", 37 | }, 38 | ]; 39 | 40 | export const meta: MetaFunction = () => [ 41 | { title: "Museum :: Welcome to the musuem" }, 42 | ]; 43 | 44 | export default function Index() { 45 | const { collections } = useLoaderData(); 46 | const navigate = useNavigate(); 47 | 48 | const [loading, setLoading] = useState(false); 49 | 50 | const browseItem = useCallback( 51 | (item?: { itemid: number } | null) => { 52 | if (!item) return; 53 | setLoading(true); 54 | navigate(`/item/${item.itemid}`); 55 | }, 56 | [navigate], 57 | ); 58 | 59 | return ( 60 | 61 | 62 | 63 | Welcome to the Museum 64 | 65 | 66 | 72 | 78 | 79 | 80 | 81 | The museum that can be found in KoL 87 | 92 | }> 93 | 94 | {(data) => } 95 | 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /app/components/ItemPageRanking.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Table } from "@chakra-ui/react"; 2 | import type { Player } from "@prisma/client"; 3 | import { Link as RRLink } from "react-router"; 4 | 5 | import CollectionInsights from "~/components/CollectionInsights"; 6 | import Rank from "~/components/Rank"; 7 | import { englishJoin } from "~/utils"; 8 | 9 | type SlimPlayer = Pick; 10 | 11 | export type Collection = { 12 | quantity: number; 13 | rank: number; 14 | player: SlimPlayer; 15 | }; 16 | 17 | type Props = { 18 | collections: Collection[]; 19 | }; 20 | 21 | function groupToMap( 22 | array: V[], 23 | callbackFn: (element: V, index?: number, array?: V[]) => K, 24 | ) { 25 | const map = new Map(); 26 | for (let i = 0; i < array.length; i++) { 27 | const key = callbackFn(array[i], i, array); 28 | const group = map.get(key) || []; 29 | group.push(array[i]); 30 | if (!map.has(key)) map.set(key, group); 31 | } 32 | return map; 33 | } 34 | 35 | export default function ItemPageRanking({ collections }: Props) { 36 | const grouped = groupToMap(collections, (c) => c.rank); 37 | const groups = [...grouped.entries()] 38 | .sort(([a], [b]) => a - b) 39 | .map(([, g]) => g); 40 | 41 | return ( 42 | <> 43 | 44 | 45 | {collections.length > 0 && ( 46 | 47 | 48 | 49 | 50 | Rank 51 | Item 52 | 53 | Quantity 54 | 55 | 56 | 57 | 58 | 59 | 60 | {groups.map((c, i, a) => ( 61 | 0 ? a[i - 1][0].quantity - c[0].quantity : 0} 65 | quantity={c[0].quantity} 66 | joint={c.length > 1} 67 | > 68 | {englishJoin( 69 | c.map(({ player }) => ( 70 | 75 | 79 | {player.name} 80 | 81 | 82 | )), 83 | )} 84 | 85 | ))} 86 | 87 | 88 | 89 | )} 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/components/CollectionInsights.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, HStack, Link, Text } from "@chakra-ui/react"; 2 | import { Link as RRLink } from "react-router"; 3 | 4 | import type { Collection } from "~/components/ItemPageRanking"; 5 | import { HOLDER_ID } from "~/utils"; 6 | 7 | type Props = { 8 | groups: Map; 9 | }; 10 | 11 | export default function CollectionInsights({ groups }: Props) { 12 | const keys = [...groups.keys()]; 13 | 14 | if (keys.length > 1) return null; 15 | 16 | if (keys.length === 0) 17 | return ( 18 | 19 | 20 | No-one has this item in their display case 21 | 22 | Not even{" "} 23 | 24 | HOldeRofSecrEts 25 | 26 | ! 27 | 28 | 29 | 30 | ); 31 | 32 | const group = groups.get(keys[0])!; 33 | 34 | if (group.length === 1 && group[0].player.playerid === HOLDER_ID) { 35 | const holder = group[0].player; 36 | return ( 37 | 38 | 39 | Looks like{" "} 40 | 41 | {holder.name} 42 | {" "} 43 | is the only player with one of these in their display case. Holder has{" "} 44 | 49 | special rights 50 | {" "} 51 | to put quest items and the like in his DC. So he wins by default. 52 | DEFAULT! DEFAULT! 53 | 54 | 55 | ); 56 | } 57 | 58 | // If more than one person has this item but the top collections only have 1... 59 | if (group.length > 1 && group[0].quantity === 1) { 60 | return ( 61 | 62 | 63 | 64 | 65 | 🥳 66 | 73 | Everyone's a winner 74 | 75 | 🍾 76 | 77 | 78 | 79 | Looks like everyone just has one of this item in their display case, 80 | so you can probably only get one per account. Nevertheless, well 81 | done them. 82 | 83 | 84 | 85 | ); 86 | } 87 | 88 | return null; 89 | } 90 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Setting { 11 | key String @id 12 | value String 13 | } 14 | 15 | /// This model has constraints using non-default deferring rules and requires additional setup for migrations. Visit https://pris.ly/d/constraint-deferring for more info. 16 | model Collection { 17 | id Int @id @default(autoincrement()) 18 | playerid Int 19 | itemid Int 20 | quantity Int 21 | rank Int 22 | lastupdated DateTime @default(now()) 23 | item Item @relation(fields: [itemid], references: [itemid], onDelete: NoAction, onUpdate: NoAction) 24 | player Player @relation(fields: [playerid], references: [playerid], onDelete: NoAction, onUpdate: NoAction) 25 | } 26 | 27 | model Item { 28 | itemid Int @id 29 | name String 30 | picture String @default("nopic") 31 | descid Int? 32 | description String? 33 | type String? 34 | itemclass String? 35 | candiscard Boolean @default(false) 36 | cantransfer Boolean @default(false) 37 | quest Boolean @default(false) 38 | gift Boolean @default(false) 39 | smith Boolean @default(false) 40 | cook Boolean @default(false) 41 | cocktail Boolean @default(false) 42 | jewelry Boolean @default(false) 43 | hands Int @default(1) 44 | multiuse Boolean @default(false) 45 | sellvalue Int @default(0) 46 | power Int @default(0) 47 | quest2 Boolean @default(false) 48 | mrstore Boolean @default(false) 49 | plural String? 50 | ambiguous Boolean @default(false) 51 | missing Boolean @default(false) 52 | collections Collection[] 53 | seen ItemSeen? 54 | } 55 | 56 | model Player { 57 | playerid Int @id 58 | name String 59 | clan Int? 60 | description String? 61 | collections Collection[] 62 | nameChanges PlayerNameChange[] 63 | } 64 | 65 | model PlayerNameChange { 66 | id Int @id @default(autoincrement()) 67 | playerid Int 68 | oldname String 69 | when DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date 70 | player Player @relation(fields: [playerid], references: [playerid], onDelete: NoAction, onUpdate: NoAction) 71 | 72 | @@unique([playerid, when]) 73 | } 74 | 75 | model DailyCollection { 76 | itemid Int @unique 77 | name String 78 | plural String? 79 | players Json 80 | } 81 | 82 | model UnrankedCollection { 83 | id Int @id @default(autoincrement()) 84 | playerid Int 85 | itemid Int 86 | quantity Int 87 | lastupdated DateTime @default(now()) 88 | } 89 | 90 | model ItemSeen { 91 | itemid Int @id 92 | when DateTime @default(now()) 93 | item Item @relation(fields: [itemid], references: [itemid], onDelete: NoAction, onUpdate: NoAction) 94 | } 95 | 96 | model PlayerNew { 97 | playerid Int @id 98 | name String 99 | clan Int? 100 | description String? 101 | 102 | @@ignore 103 | } 104 | -------------------------------------------------------------------------------- /scripts/db.ts: -------------------------------------------------------------------------------- 1 | import postgres from "postgres"; 2 | 3 | const { DATABASE_URL } = process.env; 4 | 5 | if (!DATABASE_URL) throw Error("Must specify a database URL"); 6 | 7 | export const sql = postgres(DATABASE_URL, { 8 | onnotice: () => {}, 9 | }); 10 | 11 | const createPlayerTable = (name: string) => ` 12 | CREATE TABLE IF NOT EXISTS "${name}" ( 13 | "playerid" INTEGER PRIMARY KEY, 14 | "name" TEXT NOT NULL, 15 | "clan" INTEGER, 16 | "description" TEXT 17 | ) 18 | `; 19 | 20 | export const CREATE_PLAYER_TABLE = createPlayerTable("Player"); 21 | 22 | export const CREATE_PLAYER_NEW_TABLE = createPlayerTable("PlayerNew"); 23 | 24 | export const CREATE_ITEM_TABLE = ` 25 | CREATE TABLE IF NOT EXISTS "Item" ( 26 | "itemid" INTEGER PRIMARY KEY, 27 | "name" TEXT NOT NULL, 28 | "picture" TEXT NOT NULL DEFAULT('nopic'), 29 | "descid" INTEGER, 30 | "description" TEXT, 31 | "type" TEXT, 32 | "itemclass" TEXT, 33 | "candiscard" BOOLEAN NOT NULL DEFAULT(false), 34 | "cantransfer" BOOLEAN NOT NULL DEFAULT(false), 35 | "quest" BOOLEAN NOT NULL DEFAULT(false), 36 | "gift" BOOLEAN NOT NULL DEFAULT(false), 37 | "smith" BOOLEAN NOT NULL DEFAULT(false), 38 | "cook" BOOLEAN NOT NULL DEFAULT(false), 39 | "cocktail" BOOLEAN NOT NULL DEFAULT(false), 40 | "jewelry" BOOLEAN NOT NULL DEFAULT(false), 41 | "hands" INTEGER NOT NULL DEFAULT(1), 42 | "multiuse" BOOLEAN NOT NULL DEFAULT(false), 43 | "sellvalue" INTEGER NOT NULL DEFAULT(0), 44 | "power" INTEGER NOT NULL DEFAULT(0), 45 | "quest2" BOOLEAN NOT NULL DEFAULT(false), 46 | "mrstore" BOOLEAN NOT NULL DEFAULT(false), 47 | "plural" TEXT, 48 | "ambiguous" BOOLEAN NOT NULL DEFAULT(false), 49 | "missing" BOOLEAN NOT NULL DEFAULT(false) 50 | ) 51 | `; 52 | 53 | export const CREATE_ITEM_SEEN_TABLE = ` 54 | CREATE TABLE IF NOT EXISTS "ItemSeen" ( 55 | "itemid" INTEGER NOT NULL REFERENCES "Item"("itemid") DEFERRABLE INITIALLY DEFERRED PRIMARY KEY, 56 | "when" DATE NOT NULL DEFAULT CURRENT_DATE 57 | ); 58 | `; 59 | 60 | const createCollectionTable = (name: string) => ` 61 | CREATE TABLE IF NOT EXISTS "${name}" ( 62 | "id" SERIAL PRIMARY KEY, 63 | "playerid" INTEGER NOT NULL, 64 | "itemid" INTEGER NOT NULL, 65 | "quantity" INTEGER NOT NULL, 66 | "rank" INTEGER NOT NULL DEFAULT 0, 67 | "lastupdated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP 68 | ) 69 | `; 70 | 71 | export const CREATE_COLLECTION_TABLE = createCollectionTable("Collection"); 72 | export const CREATE_UNRANKED_COLLECTION_TABLE = 73 | createCollectionTable("UnrankedCollection"); 74 | 75 | export const CREATE_DAILY_COLLECTION_TABLE = ` 76 | CREATE TABLE IF NOT EXISTS "DailyCollection" ( 77 | "itemid" INTEGER NOT NULL UNIQUE, 78 | "name" TEXT NOT NULL, 79 | "plural" TEXT, 80 | "players" JSONB NOT NULL 81 | ) 82 | `; 83 | 84 | export const CREATE_PLAYER_NAME_CHANGE_TABLE = ` 85 | CREATE TABLE IF NOT EXISTS "PlayerNameChange" ( 86 | "id" SERIAL PRIMARY KEY, 87 | "playerid" INTEGER NOT NULL REFERENCES "Player"("playerid") DEFERRABLE INITIALLY DEFERRED, 88 | "oldname" TEXT NOT NULL, 89 | "when" DATE NOT NULL DEFAULT CURRENT_DATE, 90 | UNIQUE("playerid", "when") 91 | ) 92 | `; 93 | 94 | export const CREATE_SETTING_TABLE = ` 95 | CREATE TABLE IF NOT EXISTS "Setting" ( 96 | "key" TEXT NOT NULL, 97 | "value" TEXT NOT NULL, 98 | 99 | CONSTRAINT "Setting_pkey" PRIMARY KEY ("key") 100 | ); 101 | `; 102 | -------------------------------------------------------------------------------- /app/components/Typeahead.tsx: -------------------------------------------------------------------------------- 1 | import { Group, IconButton, Input, List, Stack } from "@chakra-ui/react"; 2 | import { useCombobox } from "downshift"; 3 | import { useCallback } from "react"; 4 | import { LuArrowDown, LuArrowUp } from "react-icons/lu"; 5 | 6 | interface Props { 7 | items: T[]; 8 | itemToString: (item: T | null) => string; 9 | label?: string; 10 | loading?: boolean; 11 | onChange?: (item: T | null) => unknown; 12 | onInputChange?: (inputValue: string | undefined) => unknown; 13 | renderItem: (item: T) => React.ReactNode; 14 | } 15 | 16 | export const comboboxStyles = { display: "inline-block", marginLeft: "5px" }; 17 | 18 | export default function Typeahead({ 19 | items, 20 | itemToString, 21 | label, 22 | onChange, 23 | onInputChange, 24 | renderItem, 25 | loading, 26 | }: Props) { 27 | const { 28 | isOpen, 29 | getToggleButtonProps, 30 | getLabelProps, 31 | getMenuProps, 32 | highlightedIndex, 33 | getItemProps, 34 | getInputProps, 35 | } = useCombobox({ 36 | items, 37 | itemToString, 38 | onInputValueChange: ({ inputValue }) => { 39 | onInputChange?.(inputValue); 40 | }, 41 | onSelectedItemChange: (p) => onChange?.(p.selectedItem), 42 | }); 43 | 44 | const handleKeyDown = useCallback( 45 | (event: React.KeyboardEvent) => { 46 | if (event.key !== "Enter") return; 47 | const item = items.find( 48 | (i) => 49 | itemToString(i).toLowerCase() === 50 | event.currentTarget.value.toLowerCase(), 51 | ); 52 | if (!item) return; 53 | onChange?.(item); 54 | }, 55 | [itemToString, items, onChange], 56 | ); 57 | 58 | return ( 59 | 60 | {label && } 61 |

62 | 63 | 68 | 75 | {isOpen && items.length > 0 ? : } 76 | 77 | 78 | 0 ? "block" : "none"} 81 | bg="bg" 82 | borderStyle="solid" 83 | borderWidth={1} 84 | borderColor="border" 85 | borderRadius="md" 86 | maxHeight="180px" 87 | overflowY="auto" 88 | width={300} 89 | margin={0} 90 | marginTop={2} 91 | position="absolute" 92 | zIndex={1000} 93 | paddingX={0} 94 | paddingY={2} 95 | listStyleType="none" 96 | > 97 | {isOpen && 98 | items.map((item, index) => ( 99 | 108 | {renderItem(item)} 109 | 110 | ))} 111 | 112 |
113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /app/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Button, 4 | Group, 5 | Heading, 6 | Link, 7 | Stack, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | import { LuArrowLeft } from "react-icons/lu"; 11 | import { Link as RRLink, useLoaderData } from "react-router"; 12 | 13 | import Layout from "~/components/Layout"; 14 | import RankSymbol from "~/components/RankSymbol"; 15 | import { db } from "~/db.server"; 16 | 17 | export const loader = async () => { 18 | const { rank, quantity } = (await db.collection.findFirst({ 19 | where: { 20 | playerid: 1197090, 21 | itemid: 641, 22 | }, 23 | })) ?? { rank: 0, quantity: 0 }; 24 | 25 | if (rank <= 1) return { gausieRank: rank, gausieNeeded: 0 }; 26 | 27 | const next = await db.collection.findFirst({ 28 | where: { 29 | itemid: 641, 30 | rank: rank - 1, 31 | }, 32 | }); 33 | 34 | return { 35 | gausieRank: rank, 36 | gausieNeeded: (next?.quantity ?? 0) - quantity, 37 | }; 38 | }; 39 | 40 | export default function About() { 41 | const { gausieRank, gausieNeeded } = useLoaderData(); 42 | return ( 43 | 44 | 45 | 46 | About 47 | 48 | 49 | 55 | 56 | 57 | 58 | 59 | Museum is made by{" "} 60 | 61 | gausie 62 | {" "} 63 | from a closed data feed provided by TPTB. He collects{" "} 64 | 65 | toast 66 | {" "} 67 | and is currently ranked 68 | {gausieRank === 1 69 | ? "! Thanks for your generous help!" 70 | : `. He would be very grateful if you could help him on his quest to find the ${gausieNeeded.toLocaleString()} more required to move up the leaderboard.`} 71 | 72 | 73 | 74 | This site is supported by financial contributors to the{" "} 75 | 80 | Loathers community via Open Collective 81 | 82 | , a tool for transparent handling of funds within open source 83 | organisations. 84 | 85 | 86 | It was formerly hosted by{" "} 87 | 88 | Joe the Sauceror 89 | 90 | , whom we would like to continue to thank. 91 | 92 | 93 | It is inspired by the (currently much more powerful) service provided by 94 | the{" "} 95 | 100 | Display Case Database 101 | {" "} 102 | hosted by Coldfront for many years. 103 | 104 | 105 | The source for the website itself is hosted on{" "} 106 | 111 | GitHub 112 | 113 | . 114 | 115 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import createEmotionCache from "@emotion/cache"; 2 | import { CacheProvider as EmotionCacheProvider } from "@emotion/react"; 3 | import createEmotionServer from "@emotion/server/create-instance"; 4 | import { createReadableStreamFromReadable } from "@react-router/node"; 5 | import { isbot } from "isbot"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | import { type EntryContext, ServerRouter } from "react-router"; 8 | import { PassThrough } from "stream"; 9 | 10 | const ABORT_DELAY = 5000; 11 | 12 | export default function handleRequest( 13 | request: Request, 14 | responseStatusCode: number, 15 | responseHeaders: Headers, 16 | context: EntryContext, 17 | ) { 18 | return isbot(request.headers.get("user-agent")) 19 | ? handleBotRequest(request, responseStatusCode, responseHeaders, context) 20 | : handleBrowserRequest( 21 | request, 22 | responseStatusCode, 23 | responseHeaders, 24 | context, 25 | ); 26 | } 27 | 28 | function handleBotRequest( 29 | request: Request, 30 | responseStatusCode: number, 31 | responseHeaders: Headers, 32 | context: EntryContext, 33 | ) { 34 | return new Promise((resolve, reject) => { 35 | let didError = false; 36 | const emotionCache = createEmotionCache({ key: "css" }); 37 | 38 | const { pipe, abort } = renderToPipeableStream( 39 | 40 | 41 | , 42 | { 43 | onAllReady() { 44 | const body = new PassThrough(); 45 | const emotionServer = createEmotionServer(emotionCache); 46 | 47 | const bodyWithStyles = emotionServer.renderStylesToNodeStream(); 48 | body.pipe(bodyWithStyles); 49 | 50 | responseHeaders.set("Content-Type", "text/html"); 51 | 52 | resolve( 53 | new Response(createReadableStreamFromReadable(body), { 54 | headers: responseHeaders, 55 | status: didError ? 500 : responseStatusCode, 56 | }), 57 | ); 58 | 59 | pipe(body); 60 | }, 61 | onShellError(error: unknown) { 62 | reject(error); 63 | }, 64 | onError(error: unknown) { 65 | didError = true; 66 | 67 | console.error(error); 68 | }, 69 | }, 70 | ); 71 | 72 | setTimeout(abort, ABORT_DELAY); 73 | }); 74 | } 75 | 76 | function handleBrowserRequest( 77 | request: Request, 78 | responseStatusCode: number, 79 | responseHeaders: Headers, 80 | context: EntryContext, 81 | ) { 82 | return new Promise((resolve, reject) => { 83 | let didError = false; 84 | const emotionCache = createEmotionCache({ key: "css" }); 85 | 86 | const { pipe, abort } = renderToPipeableStream( 87 | 88 | 89 | , 90 | { 91 | onShellReady() { 92 | const body = new PassThrough(); 93 | const emotionServer = createEmotionServer(emotionCache); 94 | 95 | const bodyWithStyles = emotionServer.renderStylesToNodeStream(); 96 | body.pipe(bodyWithStyles); 97 | 98 | responseHeaders.set("Content-Type", "text/html"); 99 | 100 | resolve( 101 | new Response(createReadableStreamFromReadable(body), { 102 | headers: responseHeaders, 103 | status: didError ? 500 : responseStatusCode, 104 | }), 105 | ); 106 | 107 | pipe(body); 108 | }, 109 | onShellError(err: unknown) { 110 | reject(err); 111 | }, 112 | onError(error: unknown) { 113 | didError = true; 114 | 115 | console.error(error); 116 | }, 117 | }, 118 | ); 119 | 120 | setTimeout(abort, ABORT_DELAY); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /app/routes/item.$id.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Heading, IconButton, Image, Stack } from "@chakra-ui/react"; 2 | import { LuArrowLeft, LuArrowRight, LuHouse } from "react-icons/lu"; 3 | import { Link as RRLink, data, redirect, useLoaderData } from "react-router"; 4 | 5 | import { Route } from "./+types/api.item.$id"; 6 | import ItemDescription from "~/components/ItemDescription"; 7 | import ItemPageRanking from "~/components/ItemPageRanking"; 8 | import Layout from "~/components/Layout"; 9 | import { db } from "~/db.server"; 10 | import { itemToString } from "~/utils"; 11 | import { HttpError, type SlimItem, loadCollections } from "~/utils.server"; 12 | 13 | export const loader = async ({ params }: Route.LoaderArgs) => { 14 | const { id } = params; 15 | 16 | if (id && isNaN(parseInt(id))) { 17 | const found = await db.item.findFirst({ 18 | where: { name: { mode: "insensitive", equals: id } }, 19 | }); 20 | 21 | if (found) throw redirect(`/item/${found.itemid}`); 22 | throw data({ message: "Invalid item name" }, { status: 400 }); 23 | } 24 | 25 | if (!id) throw data({ message: "Invalid item ID" }, { status: 400 }); 26 | 27 | const itemId = parseInt(id); 28 | 29 | try { 30 | return { 31 | item: await loadCollections(itemId), 32 | prev: await db.item.findFirst({ 33 | where: { itemid: { lt: itemId }, seen: { isNot: null } }, 34 | orderBy: { itemid: "desc" }, 35 | }), 36 | next: await db.item.findFirst({ 37 | where: { itemid: { gt: itemId }, seen: { isNot: null } }, 38 | orderBy: { itemid: "asc" }, 39 | }), 40 | }; 41 | } catch (error) { 42 | if (error instanceof HttpError) throw error.toRouteError(); 43 | throw error; 44 | } 45 | }; 46 | 47 | export const meta = ({ data: { item } }: Route.MetaArgs) => [ 48 | { title: `Museum :: ${itemToString(item)}` }, 49 | ]; 50 | 51 | function ItemLayout({ 52 | children, 53 | item, 54 | prev, 55 | next, 56 | wiki, 57 | }: React.PropsWithChildren<{ 58 | item: SlimItem & { picture: string }; 59 | prev: SlimItem | null; 60 | next: SlimItem | null; 61 | wiki: boolean; 62 | }>) { 63 | const wikiLink = `https://wiki.kingdomofloathing.com/${itemToString(item)}`; 64 | 65 | return ( 66 | 67 | 68 | 69 | {itemToString(item)} 75 | 76 | 81 | 82 | 83 | 84 | 92 | 93 | 94 | 95 | 96 | 102 | {wiki && ( 103 | 113 | )} 114 | 115 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | {children} 128 | 129 | ); 130 | } 131 | 132 | export default function Item() { 133 | const { item, prev, next } = useLoaderData(); 134 | 135 | return ( 136 | 137 | 138 | 139 | 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/routes/player.$id.missing.tsx: -------------------------------------------------------------------------------- 1 | // import { 2 | // Box, 3 | // Button, 4 | // Group, 5 | // Heading, 6 | // IconButton, 7 | // Link, 8 | // Stack, 9 | // Text, 10 | // } from "@chakra-ui/react"; 11 | // import type { Prisma } from "@prisma/client"; 12 | // import { 13 | // LuArrowDown10, 14 | // LuArrowDownAZ, 15 | // LuArrowLeft, 16 | // LuCircleUser, 17 | // } from "react-icons/lu"; 18 | // import { 19 | // type LoaderFunctionArgs, 20 | // type MetaFunction, 21 | // Link as RRLink, 22 | // data, 23 | // useLoaderData, 24 | // } from "react-router"; 25 | // import { Fragment } from "react/jsx-runtime"; 26 | // import ItemName from "~/components/ItemName"; 27 | import Layout from "~/components/Layout"; 28 | 29 | // import { db } from "~/db.server"; 30 | // import { HOLDER_ID } from "~/utils"; 31 | 32 | // const normalizeSort = (sort: string | null) => { 33 | // switch (sort) { 34 | // case "itemid": 35 | // return sort; 36 | // default: 37 | // return "name"; 38 | // } 39 | // }; 40 | 41 | // const sortToOrderByQuery = ( 42 | // sort: ReturnType, 43 | // ): Prisma.ItemOrderByWithRelationInput => { 44 | // switch (sort) { 45 | // case "itemid": 46 | // return { itemid: "desc" }; 47 | // default: 48 | // return { name: "asc" }; 49 | // } 50 | // }; 51 | 52 | // export const loader = async ({ params, request }: LoaderFunctionArgs) => { 53 | // const playerid = Number(params.id); 54 | 55 | // if (!playerid) throw data("A player id must be specified", { status: 400 }); 56 | // if (playerid >= 2 ** 31) 57 | // throw data("Player not found with that id", { status: 404 }); 58 | 59 | // const player = await db.player.findUnique({ 60 | // where: { playerid }, 61 | // select: { 62 | // playerid: true, 63 | // name: true, 64 | // }, 65 | // }); 66 | 67 | // if (!player) throw data("Player not found with that id", { status: 404 }); 68 | 69 | // const url = new URL(request.url); 70 | // const sort = normalizeSort(url.searchParams.get("sort")); 71 | // const orderBy = sortToOrderByQuery(sort); 72 | 73 | // const missing = await db.item.findMany({ 74 | // where: { 75 | // quest: playerid === HOLDER_ID ? undefined : false, 76 | // missing: false, 77 | // collections: { none: { playerid } }, 78 | // seen: { isNot: null }, 79 | // }, 80 | // select: { name: true, itemid: true, ambiguous: true }, 81 | // orderBy, 82 | // }); 83 | 84 | // return { player, missing, sort }; 85 | // }; 86 | 87 | // export const meta: MetaFunction = ({ data }) => [ 88 | // { title: `Museum :: ${data?.player.name} missing items` }, 89 | // ]; 90 | 91 | export default function Missing() { 92 | return ( 93 | 94 | This route has been disabled temporarily because it's really expensive and 95 | we seem to get hundreds of requests on it every minute. 96 | 97 | ); 98 | // const { player, missing, sort } = useLoaderData(); 99 | 100 | // return ( 101 | // 102 | // 103 | // 104 | // {player.name} missing items 105 | // 106 | // 107 | // 113 | // 119 | // 120 | // 126 | // 127 | // 128 | // 129 | // 130 | // 136 | // 137 | // 138 | // 139 | // 140 | // 141 | // 142 | // 143 | 144 | // 145 | // In their foolhardy quest to collect every item, this player comes{" "} 146 | // {missing.length.toLocaleString()} short. 147 | // 148 | 149 | // 150 | // {missing.map((item, i) => ( 151 | // 152 | // {i > 0 && ", "} 153 | // 154 | // 155 | // 156 | // 157 | // 158 | // 159 | // ))} 160 | // 161 | // 162 | // ); 163 | } 164 | -------------------------------------------------------------------------------- /app/routes/player.$id._index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Group, 4 | Heading, 5 | IconButton, 6 | Link, 7 | Stack, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | import type { Prisma } from "@prisma/client"; 11 | import { 12 | LuArrowDown10, 13 | LuArrowDownAZ, 14 | LuArrowDownWideNarrow, 15 | LuArrowLeft, 16 | LuMedal, 17 | } from "react-icons/lu"; 18 | import { 19 | type LoaderFunctionArgs, 20 | type MetaFunction, 21 | Link as RRLink, 22 | data, 23 | redirect, 24 | useLoaderData, 25 | } from "react-router"; 26 | 27 | import CustomTitle from "~/components/CustomTitle"; 28 | import Formerly from "~/components/Formerly"; 29 | import Layout from "~/components/Layout"; 30 | import PlayerPageRanking from "~/components/PlayerPageRanking"; 31 | import { db } from "~/db.server"; 32 | 33 | const normalizeSort = (sort: string | null) => { 34 | switch (sort) { 35 | case "rank": 36 | case "quantity": 37 | case "itemid": 38 | return sort; 39 | default: 40 | return "name"; 41 | } 42 | }; 43 | 44 | const sortToOrderByQuery = ( 45 | sort: ReturnType, 46 | ): Prisma.CollectionOrderByWithRelationInput => { 47 | switch (sort) { 48 | case "rank": 49 | return { rank: "asc" }; 50 | case "quantity": 51 | return { quantity: "desc" }; 52 | case "itemid": 53 | return { item: { itemid: "desc" } }; 54 | default: 55 | return { item: { name: "asc" } }; 56 | } 57 | }; 58 | 59 | export const loader = async ({ params, request }: LoaderFunctionArgs) => { 60 | const { id } = params; 61 | 62 | if (id && isNaN(parseInt(id))) { 63 | const found = await db.player.findFirst({ 64 | where: { name: { mode: "insensitive", equals: id } }, 65 | }); 66 | 67 | if (found) throw redirect(`/player/${found.playerid}`); 68 | throw data({ message: "Invalid player name" }, { status: 400 }); 69 | } 70 | 71 | if (!id) throw data({ message: "Invalid player ID" }, { status: 400 }); 72 | 73 | const playerid = parseInt(id); 74 | 75 | if (!playerid) throw data("A player id must be specified", { status: 400 }); 76 | if (playerid >= 2 ** 31) 77 | throw data("Player not found with that id", { status: 404 }); 78 | 79 | const url = new URL(request.url); 80 | const sort = normalizeSort(url.searchParams.get("sort")); 81 | 82 | const orderBy = sortToOrderByQuery(sort); 83 | 84 | const player = await db.player.findUnique({ 85 | where: { playerid }, 86 | select: { 87 | playerid: true, 88 | name: true, 89 | collections: { 90 | select: { 91 | quantity: true, 92 | rank: true, 93 | item: true, 94 | }, 95 | orderBy: [orderBy, { item: { itemid: "asc" } }], 96 | }, 97 | nameChanges: { 98 | orderBy: { when: "desc" }, 99 | }, 100 | }, 101 | }); 102 | 103 | if (!player) throw data("Player not found with that id", { status: 404 }); 104 | 105 | const totalItems = player.collections 106 | .map((c) => c.quantity) 107 | .reduce((a, b) => a + b, 0); 108 | 109 | return { player, sort, totalItems }; 110 | }; 111 | 112 | export const meta: MetaFunction = ({ data }) => [ 113 | { title: `Museum :: ${data?.player.name}` }, 114 | ]; 115 | 116 | export default function Player() { 117 | const { player, sort, totalItems } = useLoaderData(); 118 | 119 | return ( 120 | 121 | 122 | 123 | {player.name} 124 | 125 | 126 | 127 | 133 | 134 | 140 | 141 | 142 | 143 | 144 | 150 | 151 | 152 | 153 | 154 | 160 | 161 | 162 | 163 | 164 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | Wow, that's{" "} 182 | {totalItems === 1 ? "1 item" : `${totalItems.toLocaleString()} items`}{" "} 183 | total! 184 | 185 | 186 | 187 | 188 | what items are this player missing? 189 | 190 | 191 | 192 | ); 193 | } 194 | -------------------------------------------------------------------------------- /scripts/etl.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { Readable } from "stream"; 3 | import { pipeline } from "stream/promises"; 4 | 5 | import { 6 | CREATE_COLLECTION_TABLE, 7 | CREATE_DAILY_COLLECTION_TABLE, 8 | CREATE_ITEM_SEEN_TABLE, 9 | CREATE_ITEM_TABLE, 10 | CREATE_PLAYER_NAME_CHANGE_TABLE, 11 | CREATE_PLAYER_NEW_TABLE, 12 | CREATE_PLAYER_TABLE, 13 | CREATE_UNRANKED_COLLECTION_TABLE, 14 | sql, 15 | } from "./db"; 16 | 17 | const auth = Buffer.from( 18 | `${process.env.KOL_HTTP_USERNAME}:${process.env.KOL_HTTP_PASSWORD}`, 19 | ).toString("base64"); 20 | 21 | async function importPlayers() { 22 | await sql`DROP TABLE IF EXISTS "PlayerNew" CASCADE`; 23 | await sql.unsafe(CREATE_PLAYER_NEW_TABLE); 24 | 25 | // Ensure the Player table 26 | await sql.unsafe(CREATE_PLAYER_TABLE); 27 | 28 | const response = await fetch( 29 | `https://dev.kingdomofloathing.com/collections/player.txt`, 30 | { headers: { Authorization: `Basic ${auth}` } }, 31 | ); 32 | 33 | const text = (await response.text()) 34 | .replace(/\n\\n/g, "\n") // Remove actual newlines from item descriptions 35 | .replace(/\r/g, ""); 36 | 37 | const source = Readable.from(text); 38 | const sink = 39 | await sql`COPY "PlayerNew" ("playerid", "name", "clan", "description") FROM STDIN WITH (HEADER MATCH, NULL 'NULL') WHERE "name" is not null`.writable(); 40 | 41 | await pipeline(source, sink); 42 | 43 | // Now let's see who's changed their name 44 | await sql.unsafe(CREATE_PLAYER_NAME_CHANGE_TABLE); 45 | 46 | const nameChanges = await sql` 47 | INSERT INTO "PlayerNameChange" ("playerid", "oldname") 48 | SELECT 49 | "PlayerNew"."playerid", "Player"."name" 50 | FROM "PlayerNew" 51 | LEFT JOIN "Player" ON "PlayerNew"."playerid" = "Player"."playerid" 52 | WHERE lower("PlayerNew"."name") != lower("Player"."name") 53 | ON CONFLICT DO NOTHING 54 | `; 55 | 56 | // And rotate out the player db 57 | await sql.begin((sql) => [ 58 | sql`ALTER TABLE "Player" DISABLE TRIGGER ALL`, 59 | sql`ALTER TABLE "Collection" DISABLE TRIGGER ALL`, 60 | sql`DELETE FROM "Player"`, 61 | sql`INSERT INTO "Player" SELECT * FROM "PlayerNew"`, 62 | sql`ALTER TABLE "Player" ENABLE TRIGGER ALL`, 63 | sql`ALTER TABLE "Collection" ENABLE TRIGGER ALL`, 64 | sql`TRUNCATE "PlayerNew"`, 65 | ]); 66 | 67 | // In v4 we can get this from the COPY query 68 | const players = await sql`SELECT COUNT(*) as "count" FROM "Player"`; 69 | console.timeLog( 70 | "etl", 71 | `Imported ${players[0].count} players and noticed ${nameChanges.count} name changes`, 72 | ); 73 | } 74 | 75 | async function importItems() { 76 | // Disable referential integrity checks 77 | await sql`ALTER TABLE IF EXISTS "ItemSeen" DISABLE TRIGGER ALL`; 78 | 79 | // Recreate the Item table 80 | await sql`DROP TABLE IF EXISTS "Item" CASCADE`; 81 | await sql.unsafe(CREATE_ITEM_TABLE); 82 | 83 | const response = await fetch( 84 | `https://dev.kingdomofloathing.com/collections/items.txt`, 85 | { headers: { Authorization: `Basic ${auth}` } }, 86 | ); 87 | 88 | const text = (await response.text()) 89 | .replace(/\n\\n/g, "\n") // Remove actual newlines from item descriptions 90 | .replace(/\r/g, ""); 91 | 92 | const source = Readable.from(text); 93 | const sink = 94 | await sql`COPY "Item" ("itemid", "name", "picture", "descid", "description", "type", "itemclass", "candiscard", "cantransfer", "quest", "gift", "smith", "cook", "cocktail", "jewelry", "hands", "multiuse", "sellvalue", "power", "quest2", "mrstore", "plural") FROM STDIN WITH (HEADER)`.writable(); 95 | 96 | await pipeline(source, sink); 97 | 98 | // Normalise plurals 99 | await sql`UPDATE "Item" SET "plural" = NULL WHERE "plural" = ''`; 100 | 101 | // Re-enable referential integrity checks 102 | await sql`ALTER TABLE IF EXISTS "ItemSeen" ENABLE TRIGGER ALL`; 103 | 104 | // In v4 we can get this from the COPY query 105 | const items = await sql`SELECT COUNT(*) as "count" FROM "Item"`; 106 | console.timeLog("etl", `Imported ${items[0].count} items`); 107 | } 108 | 109 | async function importCollections() { 110 | await sql`DROP TABLE IF EXISTS "UnrankedCollection" CASCADE`; 111 | await sql.unsafe(CREATE_UNRANKED_COLLECTION_TABLE); 112 | 113 | const response = await fetch( 114 | `https://dev.kingdomofloathing.com/collections/collections.txt`, 115 | { headers: { Authorization: `Basic ${auth}` } }, 116 | ); 117 | 118 | if (!response.body) { 119 | console.error("No body"); 120 | return; 121 | } 122 | 123 | const source = response.body; 124 | const sink = 125 | await sql`COPY "UnrankedCollection" ("playerid", "itemid", "quantity") FROM STDIN WITH (HEADER MATCH) WHERE "playerid" != 6`.writable(); 126 | 127 | await pipeline(source, sink); 128 | 129 | // In v4 we can get this from the COPY query 130 | const collections = 131 | await sql`SELECT COUNT(*) as "count" FROM "UnrankedCollection"`; 132 | console.timeLog("etl", `Imported ${collections[0].count} collections`); 133 | 134 | const unknownPlayers = await sql` 135 | DELETE FROM "UnrankedCollection" 136 | WHERE NOT EXISTS ( 137 | SELECT NULL FROM "Player" 138 | WHERE "Player"."playerid" = "UnrankedCollection"."playerid" 139 | )`; 140 | console.timeLog( 141 | "etl", 142 | `Deleted ${unknownPlayers.count} collections from unknown players`, 143 | ); 144 | 145 | await sql`DROP TABLE IF EXISTS "Collection" CASCADE`; 146 | await sql.unsafe(CREATE_COLLECTION_TABLE); 147 | await sql` 148 | INSERT INTO "Collection" 149 | SELECT 150 | "id" , 151 | "playerid", 152 | "itemid", 153 | "quantity", 154 | RANK () OVER (PARTITION BY "itemid" ORDER BY "quantity" DESC) "rank", 155 | "lastupdated" 156 | FROM "UnrankedCollection"; 157 | `; 158 | 159 | await sql`TRUNCATE "UnrankedCollection"`; 160 | 161 | console.timeLog("etl", "Ranked collections"); 162 | } 163 | 164 | async function importData() { 165 | await importItems(); 166 | await importPlayers(); 167 | await importCollections(); 168 | } 169 | 170 | async function normaliseData() { 171 | // Mark ambiguously named items 172 | const ambiguous = await sql` 173 | UPDATE "Item" SET "ambiguous" = true 174 | FROM ( 175 | SELECT "name", COUNT(*) as "count" FROM "Item" 176 | GROUP BY "name" 177 | ) as "s" 178 | WHERE "s"."count" > 1 AND "s"."name" = "Item"."name"`; 179 | console.timeLog( 180 | "etl", 181 | `Marked ${ambiguous.count} items as ambiguously named`, 182 | ); 183 | 184 | // Add filler items 185 | const unknownItems = await sql` 186 | INSERT INTO "Item" 187 | ("itemid", "name", "description", "missing") 188 | SELECT 189 | DISTINCT "itemid", 190 | 'Unknown', 191 | 'Museum heard that this item exists but doesn''t know anything about it!', 192 | true 193 | FROM "Collection" 194 | WHERE NOT EXISTS ( 195 | SELECT NULL FROM "Item" 196 | WHERE "Item"."itemid" = "Collection"."itemid" 197 | )`; 198 | console.timeLog( 199 | "etl", 200 | `Created ${unknownItems.count} filler items because they appear in collections`, 201 | ); 202 | 203 | // Add foreign key relations 204 | await sql` 205 | ALTER TABLE "Collection" 206 | ADD FOREIGN KEY ("itemid") REFERENCES "Item"("itemid") DEFERRABLE INITIALLY DEFERRED, 207 | ADD FOREIGN KEY ("playerid") REFERENCES "Player"("playerid") DEFERRABLE INITIALLY DEFERRED 208 | `; 209 | 210 | // Mark new items as seen 211 | await sql.unsafe(CREATE_ITEM_SEEN_TABLE); 212 | const seen = await sql` 213 | INSERT INTO "ItemSeen" (itemid, "when") 214 | SELECT DISTINCT c.itemid, CURRENT_DATE 215 | FROM "Collection" c 216 | LEFT JOIN "ItemSeen" s ON c.itemid = s.itemid 217 | WHERE s.itemid IS NULL; 218 | `; 219 | console.timeLog("etl", `Marked ${seen.count} items seen for the first time`); 220 | } 221 | 222 | async function pickDailyRandomCollections() { 223 | await sql`DROP TABLE IF EXISTS "DailyCollection" CASCADE`; 224 | await sql.unsafe(CREATE_DAILY_COLLECTION_TABLE); 225 | 226 | // Pick the items to include 227 | await sql` 228 | INSERT INTO "DailyCollection" 229 | ("itemid", "name", "plural", "players") 230 | SELECT 231 | "Collection"."itemid", 232 | "name", 233 | "plural", 234 | '[]'::json as "players" 235 | FROM ( 236 | SELECT "itemid" 237 | FROM "Collection" 238 | WHERE "rank" = 1 AND "quantity" > 1 239 | GROUP BY "itemid" 240 | ORDER BY RANDOM() 241 | LIMIT 10 242 | ) AS "Collection" 243 | LEFT JOIN "Item" ON "Collection"."itemid" = "Item"."itemid" 244 | `; 245 | 246 | // Then fill them with players 247 | await sql` 248 | UPDATE "DailyCollection" SET "players" = ( 249 | SELECT json_agg(json_build_object('playerid', "Player"."playerid", 'name', "Player"."name")) 250 | FROM "Collection" 251 | LEFT JOIN "Player" ON "Player"."playerid" = "Collection"."playerid" 252 | WHERE 253 | "Collection"."rank" = 1 AND 254 | "Collection"."itemid" = "DailyCollection"."itemid" 255 | ) 256 | `; 257 | 258 | console.timeLog("etl", "Picked daily collections"); 259 | } 260 | 261 | async function nextUpdateIn(seconds: number) { 262 | const timestamp = Date.now() + seconds * 1000; 263 | await sql` 264 | INSERT INTO "Setting" ("key", "value") VALUES ('nextUpdate', ${timestamp}) ON CONFLICT ("key") DO UPDATE SET "value" = ${timestamp} 265 | `; 266 | } 267 | 268 | export async function handler() { 269 | console.time("etl"); 270 | await importData(); 271 | await normaliseData(); 272 | await pickDailyRandomCollections(); 273 | await nextUpdateIn(Number(process.env.SCHEDULE) || 86400); 274 | console.timeEnd("etl"); 275 | } 276 | 277 | await handler(); 278 | process.exit(); 279 | --------------------------------------------------------------------------------