feature: praesi2

This commit is contained in:
Hikyu 2026-06-02 15:05:01 +02:00
parent aa3e89407e
commit 13154fbb53
34 changed files with 2179 additions and 94 deletions

View File

@ -323,12 +323,12 @@ Ein klarer Vorteil ist, dass das Modell extrem leserlich und einfach zu modellie
Ein klarer Nachteil ist bei uns, dass das meiste über ids referenziert wird und nur wenig wirklich verschachtelt ist. Zwar bringt eine Weapon ihren item\_type und item\_effect direkt mit, aber Player, Item, Region und Enemy zeigen größtenteils nur über ids aufeinander. Dadurch nutzen wir die eigentliche Stärke des Modells kaum und haben am Ende vor allem viele einzelne Dokumente, die sich gegenseitig referenzieren.
\subsection{Vor- und Nachteile des Graph-Modells}
\todo[inline]{Wir koennten Regionen Traversierung Pathfinding einbauen}
% \todo[inline]{Wir koennten Regionen Traversierung Pathfinding einbauen}
Ein klarer Vorteil ist, dass sich Beziehungen sehr leicht abfragen lassen.
Wir können zum Beispiel direkt verfolgen, welcher Player welche Weapon trägt oder welches Item ein Enemy fallen lässt,
ohne die Verbindungen umständlich selbst zusammenzubauen.
\todo[inline]{Wir haben keine Beziehungen, die ueber mehrere Knoten hinweg gehen. Keine komlexen beziehungen wo der graph einen hohen nutzen mit sich bringt}
% \todo[inline]{Wir haben keine Beziehungen, die ueber mehrere Knoten hinweg gehen. Keine komlexen beziehungen wo der graph einen hohen nutzen mit sich bringt}
Ein klarer Nachteil ist, dass Neo4j für unseren Fall überdimensioniert ist.
Bei uns geht es vor allem darum, die Daten des Player schnell zu schreiben und wieder auszulesen, und nicht darum, ständig tiefe Beziehungen abzufragen.
Für dieses einfache Schreiben und Lesen bringt der Graph mehr Aufwand mit, als er uns am Ende Vorteil bringt.

View File

@ -37,10 +37,6 @@
\@writefile{toc}{\contentsline {subsection}{\numberline {3.1}Vor- und Nachteile des Key-Value-Modells}{8}{subsection.3.1}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {3.2}Vor- und Nachteile des dokumentenorientierten Modells}{8}{subsection.3.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {3.3}Vor- und Nachteile des Graph-Modells}{8}{subsection.3.3}\protected@file@percent }
\@writefile{tdo}{\contentsline {todo}{Wir koennten Regionen Traversierung Pathfinding einbauen}{8}{section*.1}\protected@file@percent }
\pgfsyspdfmark {pgfid1}{19562756}{23935922}
\@writefile{tdo}{\contentsline {todo}{Wir haben keine Beziehungen, die ueber mehrere Knoten hinweg gehen. Keine komlexen beziehungen wo der graph einen hohen nutzen mit sich bringt}{8}{section*.2}\protected@file@percent }
\pgfsyspdfmark {pgfid2}{19562756}{18172368}
\BKM@entry{id=13,dest={73656374696F6E2E34},srcline={340}}{5C3337365C3337375C303030465C303030615C3030307A5C303030695C30303074}
\@writefile{toc}{\contentsline {section}{\numberline {4}Fazit}{9}{section.4}\protected@file@percent }
\global\@namedef{scr@dte@section@lastmaxnumwidth}{12.14694pt}

View File

@ -1,5 +1,5 @@
# Fdb version 4
["pdflatex"] 1780313072.59105 "Ringstats_Dokumentation.tex" "build/Ringstats_Dokumentation.pdf" "Ringstats_Dokumentation" 1780313073.65504 0
["pdflatex"] 1780687793.04109 "Ringstats_Dokumentation.tex" "build/Ringstats_Dokumentation.pdf" "Ringstats_Dokumentation" 1780687794.00438 0
"/usr/share/texmf-dist/fonts/enc/dvips/cm-super/cm-super-t1.enc" 1775415801 2971 def0b6c1f0b107b3b936def894055589 ""
"/usr/share/texmf-dist/fonts/map/fontname/texfonts.map" 1775415801 3524 cb3e574dea2d1052e39280babc910dc8 ""
"/usr/share/texmf-dist/fonts/tfm/jknappen/ec/ecbx1200.tfm" 1775415801 3584 402da0b29eafbad07963b1224b222f18 ""
@ -159,12 +159,12 @@
"/usr/share/texmf-dist/tex/latex/xcolor/xcolor.sty" 1775415801 55384 b454dec21c2d9f45ec0b793f0995b992 ""
"/usr/share/texmf-dist/tex/latex/xkeyval/xkeyval.sty" 1775415801 5006 d7e899bb5d9717c7f214e9bc0284de7b ""
"/usr/share/texmf-dist/web2c/texmf.cnf" 1775415801 43569 fd570f2fa160877d211e859f687312ba ""
"/var/lib/texmf/fonts/map/pdftex/updmap/pdftex.map" 1777228096 5385779 47c0c393a77fd4a098bacfd57d66b88a ""
"/var/lib/texmf/web2c/pdftex/pdflatex.fmt" 1778762763 3734533 c370cc050c4a9823a1fe1ab265afc57b ""
"Ringstats_Dokumentation.tex" 1780313071.55337 16584 03bd9bb6b9242fada2b5fd161766d7ed ""
"/var/lib/texmf/fonts/map/pdftex/updmap/pdftex.map" 1780436122 5385779 47c0c393a77fd4a098bacfd57d66b88a ""
"/var/lib/texmf/web2c/pdftex/pdflatex.fmt" 1780436120 3734532 282dcc45f1c728134b92f993ed6db952 ""
"Ringstats_Dokumentation.tex" 1780687790.47947 16588 b04ea6d6404d252977bf34dce650e1c0 ""
"assets/er-modell.png" 1778764597.46144 805989 0b7323ca4b38e6518c24fd9e5684655b ""
"build/Ringstats_Dokumentation.aux" 1780313073.61548 8525 dc2f45dff6b0575fd760f48e262e0d7d "pdflatex"
"build/Ringstats_Dokumentation.toc" 1780313073.61648 1305 1401ae3dd1fbee665ef3e10a49daf4b1 "pdflatex"
"build/Ringstats_Dokumentation.aux" 1780687793.96743 8079 e14d3fbb21e9c686a9967800d978e8e7 "pdflatex"
"build/Ringstats_Dokumentation.toc" 1780687793.96843 1305 1401ae3dd1fbee665ef3e10a49daf4b1 "pdflatex"
(generated)
"build/Ringstats_Dokumentation.aux"
"build/Ringstats_Dokumentation.log"

View File

@ -1,4 +1,4 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026/Arch Linux) (preloaded format=pdflatex 2026.5.14) 1 JUN 2026 13:24
This is pdfTeX, Version 3.141592653-2.6-1.40.29 (TeX Live 2026/Arch Linux) (preloaded format=pdflatex 2026.6.2) 5 JUN 2026 21:29
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
@ -737,33 +737,20 @@ Overfull \hbox (9.63942pt too wide) in paragraph at lines 296--308
[][]
[]
[7]
Package hyperref Info: bookmark level for unknown todo defaults to 0 on input l
ine 326.
Overfull \hbox (11.24713pt too wide) in paragraph at lines 326--326
[]
[]
Overfull \hbox (11.24713pt too wide) in paragraph at lines 331--331
[]
[]
[8] [9] (build/Ringstats_Dokumentation.aux)
[7] [8] [9] (build/Ringstats_Dokumentation.aux)
***********
LaTeX2e <2025-11-01>
L3 programming layer <2026-01-19>
***********
)
Here is how much of TeX's memory you used:
24546 strings out of 469462
479786 string characters out of 5468614
24473 strings out of 469462
477938 string characters out of 5468615
1009471 words of memory out of 5000000
52828 multiletter control sequences out of 15000+600000
52757 multiletter control sequences out of 15000+600000
638939 words of font info for 63 fonts, out of 8000000 for 9000
14 hyphenation exceptions out of 8191
108i,8n,108p,11253b,869s stack positions out of 10000i,1000n,20000p,200000b,200000s
108i,8n,108p,11253b,461s stack positions out of 10000i,1000n,20000p,200000b,200000s
</usr/share/texmf-dist/fonts/type1/public/cm-super/sfbx1200.pfb></usr/share/t
exmf-dist/fonts/type1/public/cm-super/sfrm1200.pfb></usr/share/texmf-dist/fonts
/type1/public/cm-super/sfrm1728.pfb></usr/share/texmf-dist/fonts/type1/public/c
@ -771,10 +758,10 @@ m-super/sfsx1200.pfb></usr/share/texmf-dist/fonts/type1/public/cm-super/sfsx144
0.pfb></usr/share/texmf-dist/fonts/type1/public/cm-super/sfsx1728.pfb></usr/sha
re/texmf-dist/fonts/type1/public/cm-super/sfsx2488.pfb></usr/share/texmf-dist/f
onts/type1/public/cm-super/sftt1200.pfb>
Output written on build/Ringstats_Dokumentation.pdf (9 pages, 608048 bytes).
Output written on build/Ringstats_Dokumentation.pdf (9 pages, 607609 bytes).
PDF statistics:
172 PDF objects out of 1000 (max. 8388607)
141 compressed objects within 2 object streams
29 named destinations out of 1000 (max. 500000)
170 PDF objects out of 1000 (max. 8388607)
139 compressed objects within 2 object streams
27 named destinations out of 1000 (max. 500000)
122 words of extra memory for PDF output out of 10000 (max. 10000000)

Binary file not shown.

Binary file not shown.

34
endpraesi/demo/client-redit/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@ -0,0 +1,110 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
## Coding Style Preferences
- Write everything WITHOUT comments.

View File

@ -0,0 +1,8 @@
# Projekt-Dokumentation: Entwicklungsreihenfolge
1. Projekt init
2. Docker compose mit redis
3. Einfache verbindung
4. Redis getestet (mit set, get und incr)
5. Init-Skript (db/init.ts) mit ~100 statischen Entitäten erstellt (für reproduzierbare Tests)
6. Client (index.ts) aktualisiert Player-Daten live und liest statische Gegner-Daten

View File

@ -0,0 +1,15 @@
# client-redit
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "client-redit",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
}
}

View File

@ -0,0 +1,210 @@
import { RedisClient } from "bun";
const redis = new RedisClient("redis://localhost:40379");
console.log("Populating database with ~100 static Ringstats entries...");
const effects = [
{ id: 1, name: "Stärke", value: 5 },
{ id: 2, name: "Geschick", value: 5 },
{ id: 3, name: "Blutung", value: 50 },
{ id: 4, name: "Gift", value: 45 },
{ id: 5, name: "Scharlachfäule", value: 50 },
{ id: 6, name: "Frost", value: 55 },
{ id: 7, name: "Heilung", value: 100 },
{ id: 8, name: "Magieschaden", value: 20 },
{ id: 9, name: "Feuerschaden", value: 30 },
{ id: 10, name: "Blitzschaden", value: 25 },
];
const types = [
{ id: 1, item_id: 101, name: "Dolch" },
{ id: 2, item_id: 102, name: "Gerades Schwert" },
{ id: 3, item_id: 103, name: "Großschwert" },
{ id: 4, item_id: 104, name: "Katana" },
{ id: 5, item_id: 105, name: "Helm" },
{ id: 6, item_id: 106, name: "Brustpanzer" },
{ id: 7, item_id: 107, name: "Stulpen" },
{ id: 8, item_id: 108, name: "Beinschutz" },
{ id: 9, item_id: 109, name: "Mittlerer Schild" },
{ id: 10, item_id: 110, name: "Talisman-Amulett" },
];
const weapons = [
{ id: 1, item_effect_id: 3, item_type_id: 4, weight: 5.5, damage: 115 }, // Uchigatana
{ id: 2, item_effect_id: 3, item_type_id: 4, weight: 6.5, damage: 118 }, // Rivers of Blood
{ id: 3, item_effect_id: 8, item_type_id: 3, weight: 10.0, damage: 160 }, // Dark Moon Greatsword
{ id: 4, item_effect_id: 1, item_type_id: 3, weight: 23.0, damage: 210 }, // Guts Greatsword
{ id: 5, item_effect_id: 3, item_type_id: 1, weight: 1.5, damage: 74 }, // Reduvia
{ id: 6, item_effect_id: 9, item_type_id: 2, weight: 4.0, damage: 105 }, // Blasphemous Blade
{ id: 7, item_effect_id: 10, item_type_id: 4, weight: 6.0, damage: 110 }, // Dragonscale Blade
{ id: 8, item_effect_id: 1, item_type_id: 3, weight: 15.0, damage: 175 }, // Claymore
{ id: 9, item_effect_id: 2, item_type_id: 1, weight: 2.0, damage: 80 }, // Misericorde
{ id: 10, item_effect_id: 6, item_type_id: 2, weight: 4.5, damage: 100 }, // Icerind Hatchet (simulated as straight sword for simplicity)
];
const armors = [
{ id: 1, item_effect_id: 1, item_type_id: 5, weight: 4.6, defense: 14 },
{ id: 2, item_effect_id: 1, item_type_id: 6, weight: 10.6, defense: 35 },
{ id: 3, item_effect_id: 1, item_type_id: 7, weight: 3.5, defense: 11 },
{ id: 4, item_effect_id: 1, item_type_id: 8, weight: 6.6, defense: 20 },
{ id: 5, item_effect_id: 8, item_type_id: 5, weight: 2.2, defense: 8 },
{ id: 6, item_effect_id: 8, item_type_id: 6, weight: 5.1, defense: 15 },
{ id: 7, item_effect_id: 9, item_type_id: 5, weight: 5.5, defense: 18 },
{ id: 8, item_effect_id: 9, item_type_id: 6, weight: 11.8, defense: 42 },
{ id: 9, item_effect_id: 10, item_type_id: 5, weight: 3.0, defense: 10 },
{ id: 10, item_effect_id: 10, item_type_id: 6, weight: 7.0, defense: 22 },
];
const shields = [
{ id: 1, item_effect_id: 1, item_type_id: 9, weight: 3.5, damage: 45, defense: 100 },
{ id: 2, item_effect_id: 8, item_type_id: 9, weight: 4.5, damage: 55, defense: 100 },
{ id: 3, item_effect_id: 9, item_type_id: 9, weight: 5.5, damage: 65, defense: 100 },
{ id: 4, item_effect_id: 10, item_type_id: 9, weight: 4.0, damage: 50, defense: 100 },
{ id: 5, item_effect_id: 6, item_type_id: 9, weight: 3.0, damage: 40, defense: 95 },
];
const talismans = [
{ id: 1, item_effect_id: 1 },
{ id: 2, item_effect_id: 2 },
{ id: 3, item_effect_id: 7 },
{ id: 4, item_effect_id: 8 },
{ id: 5, item_effect_id: 9 },
];
const items = [
// Weapons (id 1-10)
{ id: 1, name: "Uchigatana", gear_type: "weapon", gear_id: 1 },
{ id: 2, name: "Rivers of Blood", gear_type: "weapon", gear_id: 2 },
{ id: 3, name: "Dark Moon Greatsword", gear_type: "weapon", gear_id: 3 },
{ id: 4, name: "Guts Greatsword", gear_type: "weapon", gear_id: 4 },
{ id: 5, name: "Reduvia", gear_type: "weapon", gear_id: 5 },
{ id: 6, name: "Blasphemous Blade", gear_type: "weapon", gear_id: 6 },
{ id: 7, name: "Dragonscale Blade", gear_type: "weapon", gear_id: 7 },
{ id: 8, name: "Claymore", gear_type: "weapon", gear_id: 8 },
{ id: 9, name: "Misericorde", gear_type: "weapon", gear_id: 9 },
{ id: 10, name: "Icerind Hatchet", gear_type: "weapon", gear_id: 10 },
// Armors (id 11-20)
{ id: 11, name: "Knight Helm", gear_type: "armor", gear_id: 1 },
{ id: 12, name: "Knight Armor", gear_type: "armor", gear_id: 2 },
{ id: 13, name: "Knight Gauntlets", gear_type: "armor", gear_id: 3 },
{ id: 14, name: "Knight Greaves", gear_type: "armor", gear_id: 4 },
{ id: 15, name: "Astrologer Hood", gear_type: "armor", gear_id: 5 },
{ id: 16, name: "Astrologer Robe", gear_type: "armor", gear_id: 6 },
{ id: 17, name: "Radahn Helm", gear_type: "armor", gear_id: 7 },
{ id: 18, name: "Radahn Lion Armor", gear_type: "armor", gear_id: 8 },
{ id: 19, name: "Carian Knight Helm", gear_type: "armor", gear_id: 9 },
{ id: 20, name: "Carian Knight Armor", gear_type: "armor", gear_id: 10 },
// Shields (id 21-25)
{ id: 21, name: "Brass Shield", gear_type: "shield", gear_id: 1 },
{ id: 22, name: "Carian Knight Shield", gear_type: "shield", gear_id: 2 },
{ id: 23, name: "Kite Shield", gear_type: "shield", gear_id: 3 },
{ id: 24, name: "Banished Knight Shield", gear_type: "shield", gear_id: 4 },
{ id: 25, name: "Heater Shield", gear_type: "shield", gear_id: 5 },
// Talismans (id 26-30)
{ id: 26, name: "Erdtree's Favor", gear_type: "talisman", gear_id: 1 },
{ id: 27, name: "Radagon's Soreseal", gear_type: "talisman", gear_id: 2 },
{ id: 28, name: "Crimson Amber Medallion", gear_type: "talisman", gear_id: 3 },
{ id: 29, name: "Graven-Mass Talisman", gear_type: "talisman", gear_id: 4 },
{ id: 30, name: "Fire Scorpion Charm", gear_type: "talisman", gear_id: 5 },
{ id: 31, name: "Crimson Flask", gear_type: "consumable" },
{ id: 32, name: "Cerulean Flask", gear_type: "consumable" },
{ id: 33, name: "Rune Arc", gear_type: "consumable" },
{ id: 34, name: "Golden Rune [1]", gear_type: "consumable" },
{ id: 35, name: "Golden Rune [5]", gear_type: "consumable" },
{ id: 36, name: "Golden Rune [10]", gear_type: "consumable" },
{ id: 37, name: "Hero's Rune [1]", gear_type: "consumable" },
{ id: 38, name: "Lord's Rune", gear_type: "consumable" },
{ id: 39, name: "Remembrance of the Grafted", gear_type: "consumable" },
{ id: 40, name: "Remembrance of the Full Moon Queen", gear_type: "consumable" },
{ id: 41, name: "Remembrance of the Starscourge", gear_type: "consumable" },
{ id: 42, name: "Remembrance of the Omen King", gear_type: "consumable" },
{ id: 43, name: "Remembrance of the Fire Giant", gear_type: "consumable" },
{ id: 44, name: "Remembrance of the Black Blade", gear_type: "consumable" },
{ id: 45, name: "Smithing Stone [1]", gear_type: "material" },
{ id: 46, name: "Smithing Stone [4]", gear_type: "material" },
{ id: 47, name: "Somber Smithing Stone [1]", gear_type: "material" },
{ id: 48, name: "Somber Smithing Stone [7]", gear_type: "material" },
{ id: 49, name: "Glovewort [1]", gear_type: "material" },
{ id: 50, name: "Ghost Glovewort [9]", gear_type: "material" },
];
const regions = [
{ id: 1, name: "Limgrave", items: ["1", "8", "21", "26"], enemies: ["1", "2", "3", "11", "12", "13", "14"] },
{ id: 2, name: "Liurnia of the Lakes", items: ["3", "15", "16", "19", "20", "22", "29"], enemies: ["4", "5", "15", "16", "17", "18"] },
{ id: 3, name: "Caelid", items: ["4", "17", "18"], enemies: ["6", "7", "19", "20", "21", "22"] },
{ id: 4, name: "Altus Plateau", items: ["2", "23", "24"], enemies: ["8", "23", "24", "25"] },
{ id: 5, name: "Mountaintops of the Giants", items: ["10"], enemies: ["9", "26", "27", "28"] },
{ id: 6, name: "Farum Azula", items: ["6", "25", "30"], enemies: ["10", "29", "30"] },
{ id: 7, name: "Weeping Peninsula", items: [], enemies: [] },
{ id: 8, name: "Siofra River", items: [], enemies: [] },
{ id: 9, name: "Ainsel River", items: [], enemies: [] },
{ id: 10, name: "Deeproot Depths", items: [], enemies: [] },
{ id: 11, name: "Lake of Rot", items: [], enemies: [] },
{ id: 12, name: "Consecrated Snowfield", items: [], enemies: [] },
{ id: 13, name: "Miquella's Haligtree", items: [], enemies: [] },
{ id: 14, name: "Mohgwyn Palace", items: [], enemies: [] },
{ id: 15, name: "Leyndell, Royal Capital", items: [], enemies: [] },
{ id: 16, name: "Leyndell, Ashen Capital", items: [], enemies: [] },
];
const enemies = [
{ id: 1, region_id: 1, name: "Tree Sentinel", health: 3000, level: 20, damage: 70, items: ["1", "47", "34"] },
{ id: 2, region_id: 1, name: "Margit, the Fell Omen", health: 4100, level: 25, damage: 100, items: ["26", "33", "34"] },
{ id: 3, region_id: 1, name: "Godrick the Grafted", health: 6000, level: 30, damage: 150, items: ["33", "39"] },
{ id: 4, region_id: 2, name: "Rennala, Queen of the Full Moon", health: 5000, level: 40, damage: 200, items: ["15", "16", "33", "40"] },
{ id: 5, region_id: 2, name: "Red Wolf of Radagon", health: 3000, level: 35, damage: 120, items: ["29", "35", "46"] },
{ id: 6, region_id: 3, name: "Starscourge Radahn", health: 10000, level: 60, damage: 400, items: ["17", "18", "33", "41"] },
{ id: 7, region_id: 3, name: "Commander O'Neil", health: 7000, level: 50, damage: 250, items: ["33", "35"] },
{ id: 8, region_id: 4, name: "Morgott, the Omen King", health: 12000, level: 70, damage: 500, items: ["33", "42"] },
{ id: 9, region_id: 5, name: "Fire Giant", health: 25000, level: 90, damage: 800, items: ["30", "33", "43"] },
{ id: 10, region_id: 6, name: "Maliketh, the Black Blade", health: 18000, level: 110, damage: 1000, items: ["33", "44"] },
{ id: 11, region_id: 1, name: "Godrick Soldier", health: 150, level: 5, damage: 20, items: ["21", "34", "45"] },
{ id: 12, region_id: 1, name: "Wandering Noble", health: 80, level: 2, damage: 10, items: ["31", "34"] },
{ id: 13, region_id: 1, name: "Limgrave Knight", health: 350, level: 12, damage: 40, items: ["11", "12", "13", "14", "23", "45"] },
{ id: 14, region_id: 1, name: "Demi-Human", health: 120, level: 4, damage: 15, items: ["34", "45"] },
{ id: 15, region_id: 2, name: "Raya Lucaria Soldier", health: 200, level: 15, damage: 30, items: ["22", "34", "46"] },
{ id: 16, region_id: 2, name: "Marionette Soldier", health: 180, level: 16, damage: 25, items: ["45", "49"] },
{ id: 17, region_id: 2, name: "Albinauric", health: 250, level: 18, damage: 35, items: ["34", "35"] },
{ id: 18, region_id: 2, name: "Carian Knight", health: 600, level: 28, damage: 80, items: ["19", "20", "22", "47"] },
{ id: 19, region_id: 3, name: "Radahn Soldier", health: 300, level: 25, damage: 45, items: ["35", "46"] },
{ id: 20, region_id: 3, name: "Monstrous Dog", health: 800, level: 32, damage: 110, items: ["35", "49"] },
{ id: 21, region_id: 3, name: "Monstrous Crow", health: 750, level: 33, damage: 120, items: ["35", "49"] },
{ id: 22, region_id: 3, name: "Cleanrot Knight", health: 900, level: 38, damage: 100, items: ["36", "48"] },
{ id: 23, region_id: 4, name: "Leyndell Soldier", health: 400, level: 40, damage: 60, items: ["36", "46"] },
{ id: 24, region_id: 4, name: "Leyndell Knight", health: 1200, level: 48, damage: 140, items: ["24", "36", "48"] },
{ id: 25, region_id: 4, name: "Omen", health: 1500, level: 50, damage: 180, items: ["36", "48", "33"] },
{ id: 26, region_id: 5, name: "Fire Monk", health: 800, level: 55, damage: 120, items: ["36", "48"] },
{ id: 27, region_id: 5, name: "Giant Dog", health: 1100, level: 60, damage: 160, items: ["36"] },
{ id: 28, region_id: 5, name: "Troll", health: 2200, level: 65, damage: 220, items: ["37", "48"] },
{ id: 29, region_id: 6, name: "Beastman of Farum Azula", health: 1000, level: 80, damage: 200, items: ["37", "48"] },
{ id: 30, region_id: 6, name: "Banished Knight", health: 2000, level: 85, damage: 280, items: ["24", "37", "48", "50"] },
];
// Helper for hset
const setHash = async (prefix: string, list: any[]) => {
for (const obj of list) {
// extract items/enemies sets if they exist
const { items, enemies, ...hashData } = obj;
await redis.hset(`${prefix}:${obj.id}`, hashData);
if (items && items.length > 0) {
await redis.sadd(`${prefix}:${obj.id}:items`, ...items);
}
if (enemies && enemies.length > 0) {
await redis.sadd(`${prefix}:${obj.id}:enemies`, ...enemies);
}
}
};
await setHash("item_effect", effects);
await setHash("item_type", types);
await setHash("weapon", weapons);
await setHash("armor", armors);
await setHash("shield", shields);
await setHash("talisman", talismans);
await setHash("item", items);
await setHash("region", regions);
await setHash("enemy", enemies);
console.log(`Inserted ${effects.length + types.length + weapons.length + armors.length + shields.length + talismans.length + items.length + regions.length + enemies.length} primary entities into Redis.`);
console.log("Database successfully seeded.");
process.exit(0);

View File

@ -0,0 +1,6 @@
services:
redis:
image: redis:alpine
ports:
- "40379:6379"
restart: always

View File

@ -0,0 +1,144 @@
import { RedisClient } from "bun";
const redis = new RedisClient("redis://localhost:40379");
const colors = {
reset: "\x1b[0m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
magenta: "\x1b[35m",
red: "\x1b[31m",
gray: "\x1b[90m",
bold: "\x1b[1m",
};
let totalRequests = 0;
function log(msg: string, color: string = colors.reset) {
const timestamp = new Date().toISOString().split('T')[1].slice(0, -1);
console.log(`${colors.gray}[${timestamp}]${colors.reset} ${color}${msg}${colors.reset}`);
}
const introMessage = "Starting Ringstats Desktop Application...";
log(introMessage, colors.cyan);
const pingStart = performance.now();
await redis.ping();
totalRequests++;
log(`Connected to Ringstats Database successfully. (Reqs: 1 | Total: ${totalRequests} | Time: ${(performance.now() - pingStart).toFixed(2)}ms)`, colors.green);
log("Looking for Elden Ring process...", colors.gray);
await Bun.sleep(1500);
log("Found Elden Ring process!", colors.green);
log("Equipping initial gear...", colors.gray);
await redis.del("player:1:weapon", "player:1:armor", "player:1:talisman");
await redis.sadd("player:1:weapon", "1");
await redis.sadd("player:1:armor", "11");
await redis.sadd("player:1:talisman", "26");
totalRequests += 4;
let playerHealth = 100;
let playerStamina = 100;
let playerLevel = 1;
let currentRegionId = 1;
log("Entering live sync loop (press Ctrl+C to exit)...", colors.gray);
while (true) {
playerHealth = Math.floor(Math.max(10, playerHealth - (Math.random() * 10) + 5));
playerStamina = Math.floor(Math.max(10, playerStamina - (Math.random() * 20) + 10));
if (Math.random() > 0.9) {
playerLevel += 1;
}
if (Math.random() > 0.85) {
currentRegionId = Math.floor(Math.random() * 16) + 1;
const startTravel = performance.now();
const regionName = await redis.hget(`region:${currentRegionId}`, "name");
const regionEnemiesIds = await redis.smembers(`region:${currentRegionId}:enemies`);
const regionItemsIds = await redis.smembers(`region:${currentRegionId}:items`);
const enemyNames = await Promise.all(regionEnemiesIds.map(id => redis.hget(`enemy:${id}`, "name")));
const itemNames = await Promise.all(regionItemsIds.map(id => redis.hget(`item:${id}`, "name")));
const queries = 3 + regionEnemiesIds.length + regionItemsIds.length;
totalRequests += queries;
const travelTime = (performance.now() - startTravel).toFixed(2);
console.log();
log(`>>> Fast Traveled to: [ID: ${currentRegionId}] ${regionName} <<<`, colors.magenta + colors.bold);
log(`[Region Data] Enemies nearby: ${enemyNames.join(", ") || "None"}`, colors.magenta);
log(`[Region Data] Items nearby: ${itemNames.join(", ") || "None"}`, colors.magenta);
log(`[Stats] Query Time: ${travelTime}ms | Reqs in Block: ${queries} | Total Reqs: ${totalRequests}`, colors.gray);
console.log();
}
if (Math.random() > 0.90) { // 10% chance to swap gear
const newWeaponId = Math.floor(Math.random() * 10) + 1; // 1 to 10
const newArmorId = Math.floor(Math.random() * 10) + 11; // 11 to 20
const newTalismanId = Math.floor(Math.random() * 5) + 26; // 26 to 30
const startSwap = performance.now();
await redis.del("player:1:weapon", "player:1:armor", "player:1:talisman");
await redis.sadd("player:1:weapon", newWeaponId.toString());
await redis.sadd("player:1:armor", newArmorId.toString());
await redis.sadd("player:1:talisman", newTalismanId.toString());
const weaponName = await redis.hget(`item:${newWeaponId}`, "name");
const armorName = await redis.hget(`item:${newArmorId}`, "name");
const talismanName = await redis.hget(`item:${newTalismanId}`, "name");
totalRequests += 7; // 1 del + 3 sadd + 3 hget
const swapTime = (performance.now() - startSwap).toFixed(2);
console.log();
log(`>>> Gear Swapped <<<`, colors.cyan + colors.bold);
log(`Equipped: ${weaponName} | ${armorName} | ${talismanName}`, colors.cyan);
log(`[Stats] Query Time: ${swapTime}ms | Reqs in Block: 7 | Total Reqs: ${totalRequests}`, colors.gray);
console.log();
}
const startSync = performance.now();
await redis.hset("player:1", {
id: 1,
name: "Jonas",
health: playerHealth,
stamina: playerStamina,
level: playerLevel,
region_id: currentRegionId
});
totalRequests += 1;
const syncTime = (performance.now() - startSync).toFixed(2);
log(`[Live Sync] [ID: 1] Jonas - Lvl: ${playerLevel} | HP: ${playerHealth} | Stamina: ${playerStamina}`, colors.green);
log(`[Stats] Query Time: ${syncTime}ms | Reqs in Block: 1 | Total Reqs: ${totalRequests}`, colors.gray);
if (Math.random() > 0.7) {
const startLock = performance.now();
const randomEnemyId = await redis.srandmember(`region:${currentRegionId}:enemies`);
totalRequests += 1;
if (randomEnemyId) {
console.log();
log("--- Target Lock Detected ---", colors.red + colors.bold);
const enemyInfo = await redis.hgetall(`enemy:${randomEnemyId}`);
const dropItemIds = await redis.smembers(`enemy:${randomEnemyId}:items`);
const dropItemNames = await Promise.all(dropItemIds.map(id => redis.hget(`item:${id}`, "name")));
const queries = 2 + dropItemIds.length;
totalRequests += queries;
const lockTime = (performance.now() - startLock).toFixed(2);
log(`Overlay -> [ID: ${enemyInfo.id}] ${enemyInfo.name} | Lvl: ${enemyInfo.level} | HP: ${enemyInfo.health} | DMG: ${enemyInfo.damage}`, colors.yellow);
log(`Drops -> ${dropItemNames.length > 0 ? dropItemNames.join(", ") : "None"}`, colors.yellow);
log(`[Stats] Query Time: ${lockTime}ms | Reqs in Block: ${queries + 1} | Total Reqs: ${totalRequests}`, colors.gray);
console.log();
}
}
await Bun.sleep(2000);
}

View File

@ -0,0 +1,12 @@
{
"name": "client-redit",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

34
endpraesi/demo/website/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

View File

@ -0,0 +1,21 @@
# bun-react-tailwind-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@ -0,0 +1,149 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗 Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
const start = performance.now();
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);

17
endpraesi/demo/website/bun-env.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

View File

@ -0,0 +1,97 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.11",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
"@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
"@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
"@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
"@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="],
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
}
}

View File

@ -0,0 +1,4 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

View File

@ -0,0 +1,26 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
}
}

View File

@ -0,0 +1,63 @@
import { useRef, type FormEvent } from "react";
export function APITester() {
const responseInputRef = useRef<HTMLTextAreaElement>(null);
const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const form = e.currentTarget;
const formData = new FormData(form);
const endpoint = formData.get("endpoint") as string;
const url = new URL(endpoint, location.href);
const method = formData.get("method") as string;
const res = await fetch(url, { method });
const data = await res.json();
responseInputRef.current!.value = JSON.stringify(data, null, 2);
} catch (error) {
responseInputRef.current!.value = String(error);
}
};
return (
<div className="mt-8 mx-auto w-full max-w-2xl text-left flex flex-col gap-4">
<form
onSubmit={testEndpoint}
className="flex items-center gap-2 bg-[#1a1a1a] p-3 rounded-xl font-mono border-2 border-[#fbf0df] transition-colors duration-300 focus-within:border-[#f3d5a3] w-full"
>
<select
name="method"
className="bg-[#fbf0df] text-[#1a1a1a] py-1.5 px-3 rounded-lg font-bold text-sm min-w-[0px] appearance-none cursor-pointer hover:bg-[#f3d5a3] transition-colors duration-100"
>
<option value="GET" className="py-1">
GET
</option>
<option value="PUT" className="py-1">
PUT
</option>
</select>
<input
type="text"
name="endpoint"
defaultValue="/api/hello"
className="w-full flex-1 bg-transparent border-0 text-[#fbf0df] font-mono text-base py-1.5 px-2 outline-none focus:text-white placeholder-[#fbf0df]/40"
placeholder="/api/hello"
/>
<button
type="submit"
className="bg-[#fbf0df] text-[#1a1a1a] border-0 px-5 py-1.5 rounded-lg font-bold transition-all duration-100 hover:bg-[#f3d5a3] hover:-translate-y-px cursor-pointer whitespace-nowrap"
>
Send
</button>
</form>
<textarea
ref={responseInputRef}
readOnly
placeholder="Response will appear here..."
className="w-full min-h-[140px] bg-[#1a1a1a] border-2 border-[#fbf0df] rounded-xl p-3 text-[#fbf0df] font-mono resize-y focus:border-[#f3d5a3] placeholder-[#fbf0df]/40"
/>
</div>
);
}

View File

@ -0,0 +1,774 @@
import React, { useState, useEffect } from "react";
import "./index.css";
import {
Database, Home, Search, Search as SearchIcon, Map as MapIcon, Shield, Sword,
Heart, Activity, User, Box, ChevronDown, ChevronRight,
Zap, Crosshair, Droplets
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type Player = { id: string, name: string, health: string, stamina: string, level: string, region_id: string };
type Enemy = { id: string, name: string, health: string, level: string, damage: string, itemsIds: string[], region_id: string };
type Item = { id: string, name: string, gear_type?: string, gear_id?: string, stats?: Record<string, string> };
type Region = { id: string, name: string, enemiesIds: string[], itemsIds: string[], enemies?: Enemy[], items?: Item[] };
export function App() {
const [activeTab, setActiveTab] = useState<"home" | "search">("home");
const [db, setDb] = useState("redis");
const [dbDropdownOpen, setDbDropdownOpen] = useState(false);
const [playerData, setPlayerData] = useState<{player: Player, region: Region} | null>(null);
const [universe, setUniverse] = useState<{regions: Region[], enemies: Enemy[], items: Item[]} | null>(null);
// Modals
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const [selectedEnemy, setSelectedEnemy] = useState<Enemy | null>(null);
useEffect(() => {
fetch("/api/universe")
.then(r => r.json())
.then(data => setUniverse(data))
.catch(console.error);
}, []);
useEffect(() => {
if (db !== "redis") return;
const fetchPlayer = async () => {
try {
const res = await fetch("/api/player");
if (res.ok) {
const data = await res.json();
setPlayerData(data);
}
} catch (e) {
console.error(e);
}
};
fetchPlayer();
const int = setInterval(fetchPlayer, 1500);
return () => clearInterval(int);
}, [db]);
const findItemRegions = (itemId: string) => {
if (!universe) return [];
return universe.regions.filter(r => r.itemsIds?.includes(itemId));
};
const findItemEnemies = (itemId: string) => {
if (!universe) return [];
return universe.enemies.filter(e => e.itemsIds?.includes(itemId));
};
const dbOptions = [
{ id: "redis", name: "Redis" },
{ id: "mongodb", name: "MongoDB" },
{ id: "neo4j", name: "Neo4j" }
];
return (
<div className="flex h-screen bg-slate-950 text-slate-200 overflow-hidden font-sans selection:bg-indigo-500/30">
{/* Sidebar */}
<aside className="w-64 border-r border-white/5 bg-slate-900/50 backdrop-blur-xl flex flex-col relative z-20">
<div className="p-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<Shield className="w-5 h-5 text-white" />
</div>
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-200 to-purple-200">
Ringstats
</h1>
</div>
<nav className="flex-1 px-4 py-4 space-y-2">
<button
onClick={() => setActiveTab("home")}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300",
activeTab === "home"
? "bg-white/10 text-white shadow-sm border border-white/10"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
)}
>
<Home className="w-5 h-5" />
<span className="font-medium">Home</span>
</button>
<button
onClick={() => setActiveTab("search")}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300",
activeTab === "search"
? "bg-white/10 text-white shadow-sm border border-white/10"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
)}
>
<Search className="w-5 h-5" />
<span className="font-medium">Search</span>
</button>
</nav>
<div className="p-4 border-t border-white/5">
<div className="text-xs text-slate-500 text-center">Live Sync Active</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col relative">
{/* Topbar */}
<header className="h-20 border-b border-white/5 bg-slate-950/50 backdrop-blur-md flex items-center justify-between px-8 z-10">
<div className="flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
<span className="text-sm font-medium text-emerald-400">Connected to Player</span>
</div>
<div className="relative">
<button
onClick={() => setDbDropdownOpen(!dbDropdownOpen)}
className="flex items-center gap-3 px-4 py-2.5 rounded-xl bg-slate-900 border border-white/10 hover:border-white/20 transition-all text-sm font-medium"
>
<Database className="w-4 h-4 text-indigo-400" />
<span>{dbOptions.find(o => o.id === db)?.name}</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</button>
<AnimatePresence>
{dbDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 mt-2 w-48 rounded-xl bg-slate-800 border border-white/10 shadow-xl overflow-hidden z-50"
>
{dbOptions.map((option) => (
<button
key={option.id}
onClick={() => {
setDb(option.id);
setDbDropdownOpen(false);
}}
className={cn(
"w-full text-left px-4 py-3 text-sm transition-colors",
db === option.id ? "bg-indigo-500/10 text-indigo-300" : "hover:bg-white/5 text-slate-300"
)}
>
{option.name} {option.id !== "redis" && "(WIP)"}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative">
{/* Background Glow */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-indigo-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
{db !== "redis" ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500">
<Database className="w-16 h-16 mb-4 opacity-20" />
<h2 className="text-xl font-medium">Database not implemented yet.</h2>
<p>Please select Redis to view live data.</p>
</div>
) : (
<AnimatePresence mode="wait">
{activeTab === "home" ? (
<HomeView key="home" data={playerData} onItemClick={setSelectedItem} onRegionClick={setSelectedRegion} onEnemyClick={setSelectedEnemy} />
) : (
<SearchView key="search" universe={universe} onItemClick={setSelectedItem} onRegionClick={setSelectedRegion} onEnemyClick={setSelectedEnemy} />
)}
</AnimatePresence>
)}
</div>
</main>
{/* Item Details Modal */}
<AnimatePresence>
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-indigo-400" />
<h3 className="text-lg font-bold text-white">{selectedItem.name}</h3>
</div>
<button onClick={() => setSelectedItem(null)} className="text-slate-400 hover:text-white transition-colors">
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
{selectedItem.stats && Object.keys(selectedItem.stats).length > 0 && (
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Item Stats</h4>
<div className="grid grid-cols-2 gap-3">
{Object.entries(selectedItem.stats).map(([key, value]) => (
<div key={key} className="bg-white/5 p-3 rounded-lg border border-white/5 flex flex-col">
<span className="text-xs text-slate-500 capitalize">{key.replace("_", " ")}</span>
<span className="text-sm font-bold text-white">{String(value)}</span>
</div>
))}
</div>
</div>
)}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Found In Regions</h4>
<div className="space-y-2">
{findItemRegions(selectedItem.id).length > 0 ? (
findItemRegions(selectedItem.id).map(r => (
<button
key={r.id}
onClick={() => { setSelectedItem(null); setSelectedRegion(r); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<MapIcon className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium">{r.name}</span>
</button>
))
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
No known regions for this item.
</div>
)}
</div>
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Dropped By</h4>
<div className="space-y-2">
{findItemEnemies(selectedItem.id).length > 0 ? (
findItemEnemies(selectedItem.id).map(e => (
<button
key={e.id}
onClick={() => { setSelectedItem(null); setSelectedEnemy(e); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<Crosshair className="w-4 h-4 text-rose-400" />
<span className="text-sm font-medium">{e.name}</span>
</button>
))
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
Not dropped by any specific enemy.
</div>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Enemy Details Modal */}
<AnimatePresence>
{selectedEnemy && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<Sword className="w-5 h-5 text-rose-400" />
<h3 className="text-lg font-bold text-white">{selectedEnemy.name}</h3>
</div>
<button onClick={() => setSelectedEnemy(null)} className="text-slate-400 hover:text-white transition-colors">
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Health</div>
<div className="font-bold text-white flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-500" />
{selectedEnemy.health}
</div>
</div>
<div className="bg-white/5 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Damage Output</div>
<div className="font-bold text-white flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-500" />
{selectedEnemy.damage}
</div>
</div>
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Location</h4>
{universe?.regions.find(r => r.id === selectedEnemy.region_id) ? (
<button
onClick={() => {
const r = universe.regions.find(r => r.id === selectedEnemy.region_id);
if(r) {
setSelectedEnemy(null);
setSelectedRegion(r);
}
}}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<MapIcon className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium">{universe.regions.find(r => r.id === selectedEnemy.region_id)?.name}</span>
</button>
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
Unknown location.
</div>
)}
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Possible Drops</h4>
<div className="space-y-2">
{selectedEnemy.itemsIds && selectedEnemy.itemsIds.length > 0 ? (
selectedEnemy.itemsIds.map(iId => {
const itemObj = universe?.items.find(i => i.id === iId);
return itemObj ? (
<button
key={iId}
onClick={() => { setSelectedEnemy(null); setSelectedItem(itemObj); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<Droplets className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium">{itemObj.name}</span>
</button>
) : null;
})
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
No specific drops recorded.
</div>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Region Details Modal */}
<AnimatePresence>
{selectedRegion && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-lg max-h-[80vh] overflow-hidden shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<MapIcon className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-bold text-white">{selectedRegion.name}</h3>
</div>
<button onClick={() => setSelectedRegion(null)} className="text-slate-400 hover:text-white transition-colors">
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
{/* Region Enemies */}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Enemies Found Here</h4>
<div className="flex flex-wrap gap-2">
{selectedRegion.enemiesIds && selectedRegion.enemiesIds.length > 0 ? (
selectedRegion.enemiesIds.map(eId => {
const enemyObj = universe?.enemies.find(e => e.id === eId);
return enemyObj ? (
<button
key={eId}
onClick={() => { setSelectedRegion(null); setSelectedEnemy(enemyObj); }}
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/20 text-rose-300 rounded-lg text-sm flex items-center gap-2 transition-colors"
>
<Crosshair className="w-3 h-3" />
{enemyObj.name}
</button>
) : null;
})
) : (
<span className="text-sm text-slate-500">None</span>
)}
</div>
</div>
{/* Region Items */}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Loot In Region</h4>
<div className="flex flex-wrap gap-2">
{selectedRegion.itemsIds && selectedRegion.itemsIds.length > 0 ? (
selectedRegion.itemsIds.map(iId => {
const itemObj = universe?.items.find(i => i.id === iId);
return itemObj ? (
<button
key={iId}
onClick={() => { setSelectedRegion(null); setSelectedItem(itemObj); }}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 text-emerald-300 rounded-lg text-sm flex items-center gap-2 transition-colors"
>
<Box className="w-3 h-3" />
{itemObj.name}
</button>
) : null;
})
) : (
<span className="text-sm text-slate-500">None</span>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
function HomeView({ data, onItemClick, onRegionClick }: { data: any, onItemClick: (item: Item) => void, onRegionClick: (r: Region) => void }) {
if (!data) return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
);
const { player, region } = data;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-5xl mx-auto space-y-8"
>
{/* Player Stats Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="col-span-1 md:col-span-2 bg-gradient-to-br from-slate-900 to-slate-800/80 p-8 rounded-3xl border border-white/10 relative overflow-hidden group">
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative z-10 flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<User className="w-6 h-6 text-indigo-400" />
<h2 className="text-3xl font-bold text-white tracking-tight">{player.name}</h2>
</div>
<div className="text-indigo-300 font-medium">Level {player.level}</div>
</div>
<div className="text-right">
<div className="text-sm text-slate-400 mb-1">Player ID</div>
<div className="font-mono text-slate-300">#{player.id}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-8 relative z-10">
<div className="bg-black/20 rounded-2xl p-4 border border-white/5 backdrop-blur-sm">
<div className="flex items-center gap-2 text-rose-400 mb-2">
<Heart className="w-4 h-4" />
<span className="text-sm font-medium">Health</span>
</div>
<div className="text-2xl font-bold text-white">{player.health}</div>
<div className="w-full bg-slate-800 h-1.5 mt-3 rounded-full overflow-hidden">
<div className="bg-rose-500 h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, Math.max(0, parseInt(player.health)))}%` }} />
</div>
</div>
<div className="bg-black/20 rounded-2xl p-4 border border-white/5 backdrop-blur-sm">
<div className="flex items-center gap-2 text-emerald-400 mb-2">
<Zap className="w-4 h-4" />
<span className="text-sm font-medium">Stamina</span>
</div>
<div className="text-2xl font-bold text-white">{player.stamina}</div>
<div className="w-full bg-slate-800 h-1.5 mt-3 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, Math.max(0, parseInt(player.stamina)))}%` }} />
</div>
</div>
</div>
</div>
{/* Current Region Card */}
<button
onClick={() => region && onRegionClick(region)}
className="col-span-1 bg-slate-900 p-8 rounded-3xl border border-white/10 flex flex-col justify-center items-center text-center relative overflow-hidden group hover:bg-slate-800 hover:border-purple-500/50 transition-all text-left"
>
<MapIcon className="absolute -right-8 -bottom-8 w-48 h-48 text-white/5 group-hover:scale-110 group-hover:text-purple-500/10 transition-all duration-700" />
<div className="relative z-10">
<div className="w-12 h-12 bg-white/5 group-hover:bg-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/10 group-hover:border-purple-500/30 transition-colors">
<MapIcon className="w-6 h-6 text-purple-400 group-hover:text-purple-300" />
</div>
<h3 className="text-sm text-slate-400 font-medium tracking-widest uppercase mb-2">Current Location</h3>
<div className="text-2xl font-bold text-white group-hover:text-purple-100 transition-colors">{region?.name || "Unknown"}</div>
<div className="mt-3 text-xs text-purple-400 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
View Details <ChevronRight className="w-3 h-3" />
</div>
</div>
</button>
</div>
{region && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Enemies List */}
<div className="space-y-4">
<div className="flex items-center gap-3 px-2">
<Crosshair className="w-5 h-5 text-rose-400" />
<h3 className="text-xl font-bold">Enemies Nearby</h3>
<span className="ml-auto bg-rose-500/10 text-rose-400 py-1 px-3 rounded-full text-sm font-medium">
{region.enemies?.length || 0}
</span>
</div>
<div className="space-y-3">
{region.enemies?.length === 0 && (
<div className="p-6 text-center text-slate-500 border border-white/5 border-dashed rounded-2xl">
No enemies in this region.
</div>
)}
{region.enemies?.map((enemy: Enemy) => (
<EnemyCard key={enemy.id} enemy={enemy} onItemClick={onItemClick} allItems={region.items} allRegions={[region]} />
))}
</div>
</div>
{/* Items List */}
<div className="space-y-4">
<div className="flex items-center gap-3 px-2">
<Box className="w-5 h-5 text-emerald-400" />
<h3 className="text-xl font-bold">Loot Nearby</h3>
<span className="ml-auto bg-emerald-500/10 text-emerald-400 py-1 px-3 rounded-full text-sm font-medium">
{region.items?.length || 0}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
{region.items?.length === 0 && (
<div className="col-span-2 p-6 text-center text-slate-500 border border-white/5 border-dashed rounded-2xl">
No items in this region.
</div>
)}
{region.items?.map((item: Item) => (
<button
key={item.id}
onClick={() => onItemClick(item)}
className="flex items-center gap-3 p-4 bg-slate-900 border border-white/5 rounded-2xl hover:bg-white/5 hover:border-white/10 transition-all text-left group"
>
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center group-hover:scale-110 transition-transform">
{item.gear_type === "weapon" ? <Sword className="w-5 h-5 text-orange-400" /> :
item.gear_type === "armor" || item.gear_type === "shield" ? <Shield className="w-5 h-5 text-indigo-400" /> :
<Box className="w-5 h-5 text-emerald-400" />}
</div>
<div>
<div className="font-medium text-white truncate max-w-[120px]">{item.name}</div>
<div className="text-xs text-slate-500 capitalize">{item.gear_type || "Item"}</div>
</div>
</button>
))}
</div>
</div>
</div>
)}
</motion.div>
);
}
function EnemyCard({ enemy, onItemClick, allItems, allRegions }: { enemy: Enemy, onItemClick: (item: Item) => void, allItems?: Item[], allRegions?: Region[] }) {
const [expanded, setExpanded] = useState(false);
// Find region name
const regionName = allRegions?.find(r => r.id === enemy.region_id)?.name;
return (
<div className="bg-slate-900 border border-white/5 rounded-2xl overflow-hidden transition-all duration-300">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between p-5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-rose-500/10 flex items-center justify-center border border-rose-500/20 shrink-0">
<Sword className="w-6 h-6 text-rose-400" />
</div>
<div className="text-left flex-1">
<div className="font-bold text-white text-lg">{enemy.name}</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs font-medium px-2 py-0.5 rounded bg-slate-800 text-slate-300">Lvl {enemy.level}</span>
{regionName && (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1 truncate max-w-[150px]">
<MapIcon className="w-3 h-3 text-purple-400" />
{regionName}
</span>
)}
</div>
</div>
</div>
<motion.div animate={{ rotate: expanded ? 90 : 0 }}>
<ChevronRight className="w-5 h-5 text-slate-500" />
</motion.div>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-white/5 bg-black/20"
>
<div className="p-5 grid grid-cols-2 gap-4">
<div className="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Health</div>
<div className="font-bold text-white flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-500" />
{enemy.health}
</div>
</div>
<div className="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Damage Output</div>
<div className="font-bold text-white flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-500" />
{enemy.damage}
</div>
</div>
<div className="col-span-2 mt-2">
<div className="text-xs text-slate-500 mb-2 uppercase tracking-wider font-semibold">Possible Drops</div>
<div className="flex flex-wrap gap-2">
{enemy.itemsIds && enemy.itemsIds.length > 0 ? (
enemy.itemsIds.map(itemId => {
const found = allItems?.find(i => i.id === itemId);
const itemName = found ? found.name : `Drop #${itemId}`;
return (
<button
key={itemId}
onClick={(e) => { e.stopPropagation(); onItemClick({ id: itemId, name: itemName }); }}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 border border-white/10 rounded-lg text-sm text-slate-300 transition-colors flex items-center gap-2"
>
<Droplets className="w-3 h-3 text-emerald-400" />
{itemName}
</button>
)})
) : (
<div className="text-sm text-slate-500 italic">No specific drops recorded.</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function SearchView({ universe, onItemClick, onRegionClick }: { universe: any, onItemClick: (item: Item) => void, onRegionClick: (r: Region) => void }) {
const [query, setQuery] = useState("");
if (!universe) return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
);
const q = query.toLowerCase();
const regionsMatch = universe.regions.filter((r: Region) => r.name.toLowerCase().includes(q));
const enemiesMatch = universe.enemies.filter((e: Enemy) => e.name.toLowerCase().includes(q));
const itemsMatch = universe.items.filter((i: Item) => i.name.toLowerCase().includes(q));
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-5xl mx-auto space-y-8 h-full flex flex-col"
>
<div className="relative">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search for regions, items, enemies..."
className="w-full bg-slate-900 border border-white/10 rounded-2xl py-4 pl-12 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all shadow-xl"
/>
</div>
<div className="flex-1 overflow-y-auto space-y-8 pb-12">
{regionsMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<MapIcon className="w-5 h-5" /> Regions
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{regionsMatch.map((r: Region) => (
<button
key={r.id}
onClick={() => onRegionClick(r)}
className="bg-slate-900 p-4 rounded-xl border border-white/5 hover:bg-white/10 hover:border-purple-500/50 transition-all flex items-center justify-between text-left group"
>
<span className="font-medium text-white group-hover:text-purple-200 transition-colors">{r.name}</span>
<ChevronRight className="w-4 h-4 text-slate-500 group-hover:text-purple-400 transition-colors" />
</button>
))}
</div>
</div>
)}
{enemiesMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<Crosshair className="w-5 h-5" /> Enemies
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{enemiesMatch.map((e: Enemy) => (
<EnemyCard key={e.id} enemy={e} onItemClick={onItemClick} allItems={universe.items} allRegions={universe.regions} />
))}
</div>
</div>
)}
{itemsMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<Box className="w-5 h-5" /> Items
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{itemsMatch.map((i: Item) => (
<button
key={i.id}
onClick={() => onItemClick(i)}
className="bg-slate-900 p-4 rounded-xl border border-white/5 hover:bg-white/5 transition-colors text-left group"
>
<div className="font-medium text-white mb-1 group-hover:text-indigo-300 transition-colors">{i.name}</div>
<div className="text-xs text-slate-500 capitalize">{i.gear_type || "Item"}</div>
</button>
))}
</div>
</div>
)}
{query && regionsMatch.length === 0 && enemiesMatch.length === 0 && itemsMatch.length === 0 && (
<div className="text-center text-slate-500 py-12">
No results found for "{query}".
</div>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,20 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { createRoot } from "react-dom/client";
import { App } from "./App";
function start() {
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}

View File

@ -0,0 +1,11 @@
@import "tailwindcss";
@layer base {
:root {
@apply font-sans;
}
body {
@apply m-0 p-0 overflow-hidden bg-slate-950 text-slate-200;
}
}

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Bun + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,118 @@
import { serve, RedisClient } from "bun";
import index from "./index.html";
const redis = new RedisClient("redis://localhost:40379");
const server = serve({
routes: {
"/*": index,
"/api/player": async (req) => {
try {
const player = await redis.hgetall("player:1");
if (!player || !player.id) {
return Response.json({ error: "Player not found" }, { status: 404 });
}
const currentRegionId = player.region_id;
let region = null;
if (currentRegionId) {
const regionName = await redis.hget(`region:${currentRegionId}`, "name");
const enemiesIds = await redis.smembers(`region:${currentRegionId}:enemies`);
const itemsIds = await redis.smembers(`region:${currentRegionId}:items`);
const enemies = [];
for (const id of enemiesIds) {
const enemy = await redis.hgetall(`enemy:${id}`);
if (enemy && enemy.id) enemies.push(enemy);
}
const items = [];
for (const id of itemsIds) {
const item = await redis.hgetall(`item:${id}`);
if (item && item.id) items.push(item);
}
region = {
id: currentRegionId,
name: regionName,
enemies,
items
};
}
return Response.json({ player, region });
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500 });
}
},
"/api/universe": async (req) => {
try {
// Fetch all regions 1-16
const regions = [];
for (let i = 1; i <= 16; i++) {
const name = await redis.hget(`region:${i}`, "name");
if (name) {
const enemiesIds = await redis.smembers(`region:${i}:enemies`);
const itemsIds = await redis.smembers(`region:${i}:items`);
regions.push({ id: i.toString(), name, enemiesIds, itemsIds });
}
}
// Fetch enemies 1-30
const enemies = [];
for (let i = 1; i <= 30; i++) {
const enemy = await redis.hgetall(`enemy:${i}`);
if (enemy && enemy.id) {
const itemsIds = await redis.smembers(`enemy:${i}:items`);
enemy.itemsIds = itemsIds || [];
enemies.push(enemy);
}
}
// Fetch items 1-50
const items = [];
for (let i = 1; i <= 50; i++) {
const item = await redis.hgetall(`item:${i}`);
if (item && item.id) {
if (item.gear_type && item.gear_id) {
const gearStats = await redis.hgetall(`${item.gear_type}:${item.gear_id}`);
if (gearStats.item_type_id) {
const typeInfo = await redis.hgetall(`item_type:${gearStats.item_type_id}`);
if (typeInfo && typeInfo.name) gearStats.Item_Type = typeInfo.name;
delete gearStats.item_type_id;
}
if (gearStats.item_effect_id) {
const effectInfo = await redis.hgetall(`item_effect:${gearStats.item_effect_id}`);
if (effectInfo && effectInfo.name) {
gearStats.Item_Effect = `${effectInfo.name} ${effectInfo.value ? `(+${effectInfo.value})` : ""}`;
}
delete gearStats.item_effect_id;
}
delete gearStats.id; // redundant
item.stats = gearStats;
}
items.push(item);
}
}
return Response.json({ regions, enemies, items });
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500 });
}
}
},
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);

View File

@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}

View File

@ -38,14 +38,14 @@ class: text-center
<div class="pt-3 font-semibold text-sm">Szenario</div>
</div>
<div class="flex items-center text-2xl opacity-40"></div>
<div class="flex items-center text-2xl opacity-40"></div>
<div class="w-36 p-5 rounded-xl bg-gray-400/10">
<div class="text-4xl">🗺️</div>
<div class="pt-3 font-semibold text-sm">ER-Modell</div>
</div>
<div class="flex items-center text-2xl opacity-40"></div>
<div class="flex items-center text-2xl opacity-40"></div>
<div class="w-36 p-5 rounded-xl bg-gray-400/10">
<div class="text-4xl">🧩</div>
@ -270,9 +270,10 @@ enemy:1
</div>
<!--
- Keys sind Strings und identifizieren ein Objekt eindeutig
- Aufbau: Name der Entität, Doppelpunkt, ID
- meiste Entitäten als Hash Map, Listen und Mengen als SET
- Beim Key-Value-Modell läuft alles über Schlüssel
- Jeder Key ist ein String und steht für genau ein Objekt
- Aufbau ist einfach: Name der Entität, Doppelpunkt, ID
- Objekte speichern wir als Hash Map, Listen und Mengen als SET
-->
---
@ -296,9 +297,9 @@ layout: default
</div>
<!--
- jede Entität bekommt einen eigenen Key
- Ausrüstung des Players über Sub-Keys, z.B. player:1:armor
- über den Key direkt ans Objekt, ohne Suchen
- Jede Entität bekommt ihren eigenen Key
- Die Ausrüstung hängen wir über Sub-Keys dran, z.B. player:1:armor
- So kommen wir über den Key direkt ans Objekt, ganz ohne Suchen
-->
---
@ -340,9 +341,9 @@ player {
</div>
<!--
- alles Zusammengehörige gebündelt in einem Dokument
- jede Entität eigene Collection
- Player hält von der Ausrüstung nur die ids fest
- Beim Dokument-Modell packen wir Zusammengehöriges in ein Dokument
- Jede Entität bekommt ihre eigene Collection
- Vom Spieler aus halten wir bei der Ausrüstung nur die ids fest
-->
---
@ -398,9 +399,9 @@ Weapon trägt item_type und item_effect direkt. Sonst meist nur ids.
</div>
<!--
- Weapon bringt item_type und item_effect direkt mit
- Die Weapon bringt item_type und item_effect direkt mit
- Enemy und Region verweisen über ids auf ihre Items
- bei uns meist nur ids statt tiefer Verschachtelung
- Bei uns sind es also meist nur ids statt tiefer Verschachtelung
-->
---
@ -431,8 +432,9 @@ Jede Entität ein Knoten mit eigenen Werten.
</div>
<!--
- Fokus liegt auf den Beziehungen zwischen Objekten
- jede Entität wird ein Knoten und trägt seine Werte direkt
- Beim Graph-Modell liegt der Fokus auf den Beziehungen
- Jede Entität wird ein Knoten und trägt ihre Werte direkt
- Die Verbindungen kommen dann über die Kanten dazu
-->
---
@ -457,9 +459,9 @@ Kanten wie im ER-Modell.
</div>
<!--
- Kanten sind die Beziehungen aus dem ER-Modell
- has_equipped, is_in, has, drops
- Beziehungen lassen sich direkt verfolgen
- Die Kanten sind genau die Beziehungen aus dem ER-Modell
- Zum Beispiel has_equipped, is_in, has und drops
- Damit lassen sich die Beziehungen direkt verfolgen
-->
---
@ -483,9 +485,9 @@ Komplexe Abfragen müssen wir selbst zusammenbauen.
</div>
<!--
- Vorteil: extrem schnell, direkter Zugriff über Key, ideal für Live-Daten
- Nachteil: komplexe Abfragen müssen wir selbst zusammenbauen
- Beispiel: alle Weapon mit bestimmtem item_effect
- Der Vorteil ist das Tempo: über den Key direkt ans Objekt, ideal für Live-Daten
- Der Nachteil: komplexe Abfragen müssen wir selbst zusammenbauen
- Zum Beispiel alle Weapon mit einem bestimmten item_effect
-->
---
@ -509,9 +511,9 @@ Bei uns meist nur ids. Stärke kaum genutzt.
</div>
<!--
- Vorteil: sehr leserlich und einfach zu modellieren
- Nachteil: bei uns meist ids, kaum verschachtelt
- die eigentliche Stärke nutzen wir kaum
- Der Vorteil: sehr leserlich und einfach zu modellieren
- Der Nachteil: bei uns sind es meist nur ids, kaum verschachtelt
- Die eigentliche Stärke nutzen wir damit kaum aus
-->
---
@ -535,37 +537,49 @@ Für uns überdimensioniert. Wir brauchen vor allem Tempo.
</div>
<!--
- Vorteil: Beziehungen sehr leicht abfragbar
- Beispiel: welcher Player welche Weapon trägt, welches Item ein Enemy droppt
- Nachteil: für unseren Fall überdimensioniert
- wir brauchen schnelles Schreiben und Lesen, keine tiefen Abfragen
- Der Vorteil: Beziehungen lassen sich super leicht abfragen
- Etwa welcher Player welche Weapon trägt oder welches Item ein Enemy droppt
- Der Nachteil: für unseren Fall ist das überdimensioniert
- Wir brauchen schnelles Schreiben und Lesen, keine tiefen Abfragen
-->
---
layout: default
---
# Gegenüberstellung
# Was bringt uns welches Modell?
<div class="pt-8 text-lg">
<div class="grid grid-cols-3 gap-6 pt-12 text-center">
| | Key-Value | Dokument | Graph |
|---|:---:|:---:|:---:|
| Schreiben und Lesen | ✅ sehr schnell | 🟡 gut | 🟡 mehr Aufwand |
| Direkter Objektzugriff | ✅ über Key | ✅ über Collection | 🟡 über Knoten |
| Komplexe Abfragen | 🔴 schwierig | 🟡 mittel | ✅ stark |
| Passt zu Live Daten | ✅ ideal | 🟡 ok | 🔴 überdimensioniert |
<div class="p-6 rounded-xl bg-red-400/10">
<div class="text-2xl font-bold">Key-Value</div>
<div class="pt-4 text-3xl"></div>
<div class="pt-3 text-sm opacity-80">Live-Daten des Players sofort lesen</div>
</div>
<div class="p-6 rounded-xl bg-green-400/10">
<div class="text-2xl font-bold">Dokument</div>
<div class="pt-4 text-3xl">📖</div>
<div class="pt-3 text-sm opacity-80">Ganzer Player mit Ausrüstung in einem Dokument</div>
</div>
<div class="p-6 rounded-xl bg-blue-400/10">
<div class="text-2xl font-bold">Graph</div>
<div class="pt-4 text-3xl">🔗</div>
<div class="pt-3 text-sm opacity-80">Welches Item ein Enemy droppt, direkt abfragbar</div>
</div>
</div>
<div class="pt-8 opacity-70">
Unsere Daten sind flach und ständig in Bewegung.
<div class="pt-10 text-center opacity-70">
Wir brauchen vor allem Tempo.
</div>
<!--
- Tabelle fasst die drei Modelle zusammen
- Key-Value vorn bei Tempo und direktem Zugriff
- unsere Daten flach und ständig in Bewegung
- Key-Value gibt uns Tempo pur
- Dokument ist schön übersichtlich
- Graph ist stark bei den Beziehungen
- Für uns zählt am Ende vor allem das Tempo
-->
---
@ -579,9 +593,10 @@ layout: default
| | Redis | MongoDB | Neo4j |
|---|:---:|:---:|:---:|
| Performanz | 2 | 1 | 1 |
| Streaming | 2 | 0 | 0 |
| Latenz | 2 | 0 | 1 |
| **Summe** | **30** | **15** | **13** |
| Indexing | 1 | 1 | 1 |
| Latenz | 2 | 0 | 0 |
| ... weitere Kriterien | ... | ... | ... |
| **Summe** | **30** | **15** | **14** |
</div>
@ -590,9 +605,9 @@ Redis gewinnt klar.
</div>
<!--
- Kriterien aus dem Szenario gewichtet
- Performanz und keine Lizenzkosten sind Pflicht
- in den Tests gewinnt Redis klar: 30 vs 15 vs 13
- Wir haben Kriterien aus dem Szenario aufgestellt und gewichtet
- Performanz und keine Lizenzkosten sind für uns Pflicht
- Am Ende gewinnt Redis klar: 30 gegen 15 gegen 14
-->
---
@ -626,9 +641,9 @@ Flache Daten, keine tiefe Verschachtelung.
</div>
<!--
- Wahl fällt auf Key-Value mit Redis
- drei Gründe: Geschwindigkeit, direkter Zugriff, passende Größe
- komplexe Abfragen brauchen wir kaum
- Unsere Wahl fällt auf Key-Value mit Redis
- Drei Gründe: Geschwindigkeit, direkter Zugriff und die passende Größe
- Komplexe Abfragen brauchen wir bei uns kaum
-->
---
@ -636,12 +651,7 @@ layout: center
class: text-center
---
# Danke
<div class="opacity-80 pt-4 text-lg">
Nächste Schritte: Datenbank auf dem Server aufsetzen und Schema umsetzen
</div>
# Danke fürs Zuhören
<div class="pt-10 opacity-60">
Fragen?
</div>
@ -651,7 +661,7 @@ Justin Rost · Michelle Reichelt · Yasin Ergün
</div>
<!--
- Dank fürs Zuhören
- nächste Schritte: Datenbank auf Server aufsetzen, Schema umsetzen
- offen für Fragen
- Danke fürs Zuhören
- Als nächstes setzen wir die Datenbank auf dem Server auf und bauen das Schema um
- Jetzt sind wir offen für eure Fragen
-->