feature: praesi2
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "40379:6379"
|
||||
restart: always
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "client-redit",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`);
|
||||
Vendored
+17
@@ -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;
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user