Phase 1: scaffold + game data ingestion

npm workspaces (Fastify+TS server, Vite+React web), SQLite schema,
ao-bin-dumps downloader and parser (items, recipes, enchant upgrades,
EN localization), item browser UI and debug API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 02:53:19 +05:00
commit 7ba275dd77
22 changed files with 4703 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
server/data/
*.log
.env

3758
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "albion-economy-assistant",
"version": "0.1.0",
"private": true,
"description": "Albion Online economy assistant: profit calculator, production chain ranker, travel planner, market watcher",
"workspaces": [
"server",
"web"
],
"scripts": {
"dev": "concurrently -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "npm run dev -w server",
"dev:web": "npm run dev -w web",
"gamedata": "npm run gamedata -w server",
"test": "npm run test -w server"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

24
server/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@albion/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"gamedata": "tsx src/gamedata/cli.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^12.2.0",
"fastify": "^5.4.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.0.0",
"tsx": "^4.20.0",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
}
}

21
server/src/config.ts Normal file
View file

@ -0,0 +1,21 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Server root directory (server/) */
export const SERVER_ROOT = path.resolve(__dirname, '..');
/** Local data directory (git-ignored): SQLite db + downloaded game data */
export const DATA_DIR = path.join(SERVER_ROOT, 'data');
export const GAMEDATA_DIR = path.join(DATA_DIR, 'gamedata');
export const DB_PATH = path.join(DATA_DIR, 'app.db');
export const API_PORT = 4310;
/** Albion server region for market data. Overridable in settings later. */
export const DEFAULT_ALBION_SERVER = 'europe';
/** ao-bin-dumps raw file base URL */
export const AO_BIN_DUMPS_BASE =
'https://raw.githubusercontent.com/ao-data/ao-bin-dumps/master';

72
server/src/db/index.ts Normal file
View file

@ -0,0 +1,72 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { DB_PATH } from '../config.js';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
unique_name TEXT PRIMARY KEY, -- e.g. T4_BAG@1
base_name TEXT NOT NULL, -- e.g. T4_BAG
enchantment INTEGER NOT NULL DEFAULT 0,
tier INTEGER,
weight REAL,
item_value INTEGER,
shop_category TEXT,
shop_subcategory TEXT,
crafting_category TEXT,
name_en TEXT
);
CREATE INDEX IF NOT EXISTS idx_items_base ON items(base_name);
CREATE INDEX IF NOT EXISTS idx_items_name_en ON items(name_en);
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
output_item TEXT NOT NULL,
variant INTEGER NOT NULL DEFAULT 0, -- alternative recipe index for the same output
amount_crafted REAL NOT NULL DEFAULT 1,
silver_cost REAL NOT NULL DEFAULT 0,
focus_cost REAL NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'crafting' -- 'crafting' | 'upgrade' (enchanting)
);
CREATE INDEX IF NOT EXISTS idx_recipes_output ON recipes(output_item);
CREATE TABLE IF NOT EXISTS recipe_inputs (
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
input_item TEXT NOT NULL,
count REAL NOT NULL,
max_return_amount REAL, -- 0 means the resource is never returned (e.g. artifacts)
PRIMARY KEY (recipe_id, input_item)
);
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
`;
let db: Database.Database | null = null;
export function getDb(): Database.Database {
if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
}
return db;
}
export function getMeta(key: string): string | null {
const row = getDb().prepare('SELECT value FROM meta WHERE key = ?').get(key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
export function setMeta(key: string, value: string): void {
getDb()
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
}

View file

@ -0,0 +1,35 @@
import { downloadGameData } from './download.js';
import { parseGameData } from './parse.js';
import { getDb } from '../db/index.js';
const force = process.argv.includes('--force');
console.log(`Downloading game data${force ? ' (forced refresh)' : ''}...`);
const downloads = await downloadGameData(force);
for (const d of downloads) {
console.log(` ${d.file}: ${d.status} (${(d.bytes / 1024 / 1024).toFixed(1)} MB)`);
}
console.log('Parsing into database...');
const stats = parseGameData();
console.log(` items: ${stats.items}, recipes: ${stats.recipes}, inputs: ${stats.recipeInputs}, localized names: ${stats.localizedNames}`);
// Sanity check: a well-known recipe (Adept's Bag = 8 T4 leather)
const db = getDb();
const bag = db
.prepare(
`SELECT r.id, r.output_item, r.focus_cost, i.name_en
FROM recipes r JOIN items i ON i.unique_name = r.output_item
WHERE r.output_item = 'T4_BAG' AND r.source = 'crafting'`,
)
.all() as any[];
for (const r of bag) {
const inputs = db
.prepare('SELECT input_item, count, max_return_amount FROM recipe_inputs WHERE recipe_id = ?')
.all(r.id);
console.log(`Sanity check — ${r.name_en} (${r.output_item}), focus ${r.focus_cost}:`, inputs);
}
if (bag.length === 0) {
console.error('WARNING: T4_BAG recipe not found — parser needs adjustment.');
process.exitCode = 1;
}

View file

@ -0,0 +1,51 @@
import fs from 'node:fs';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { AO_BIN_DUMPS_BASE, GAMEDATA_DIR } from '../config.js';
/** Files pulled from ao-data/ao-bin-dumps. Add zone/world files in the travel phase. */
export const GAMEDATA_FILES = [
{ remote: 'items.json', local: 'items.json' },
{ remote: 'formatted/items.json', local: 'items_localized.json' },
] as const;
export function gamedataFilePath(local: string): string {
return path.join(GAMEDATA_DIR, local);
}
async function downloadFile(remote: string, dest: string): Promise<void> {
const url = `${AO_BIN_DUMPS_BASE}/${remote}`;
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(`Download failed for ${url}: HTTP ${res.status}`);
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = `${dest}.download`;
await pipeline(Readable.fromWeb(res.body as import('node:stream/web').ReadableStream), fs.createWriteStream(tmp));
fs.renameSync(tmp, dest);
}
export interface DownloadResult {
file: string;
status: 'downloaded' | 'cached';
bytes: number;
}
/**
* Ensure all game data files exist locally. With force=true, re-download everything
* (used by the "refresh game data" action after a game patch).
*/
export async function downloadGameData(force = false): Promise<DownloadResult[]> {
const results: DownloadResult[] = [];
for (const f of GAMEDATA_FILES) {
const dest = gamedataFilePath(f.local);
if (!force && fs.existsSync(dest)) {
results.push({ file: f.local, status: 'cached', bytes: fs.statSync(dest).size });
continue;
}
await downloadFile(f.remote, dest);
results.push({ file: f.local, status: 'downloaded', bytes: fs.statSync(dest).size });
}
return results;
}

View file

@ -0,0 +1,225 @@
import fs from 'node:fs';
import { getDb, setMeta } from '../db/index.js';
import { gamedataFilePath } from './download.js';
/**
* Parser for ao-bin-dumps items.json (the game's item/recipe dump, converted from XML —
* attributes appear as "@name" keys, and any child element may be an object or an array).
*/
type Raw = Record<string, any>;
function asArray<T>(v: T | T[] | undefined | null): T[] {
if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v];
}
function num(v: unknown): number | null {
if (v === undefined || v === null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
interface ParsedItem {
uniqueName: string;
baseName: string;
enchantment: number;
tier: number | null;
weight: number | null;
itemValue: number | null;
shopCategory: string | null;
shopSubcategory: string | null;
craftingCategory: string | null;
}
interface ParsedRecipe {
outputItem: string;
variant: number;
amountCrafted: number;
silverCost: number;
focusCost: number;
source: 'crafting' | 'upgrade';
inputs: { item: string; count: number; maxReturnAmount: number | null }[];
}
export interface ParseStats {
items: number;
recipes: number;
recipeInputs: number;
localizedNames: number;
}
function parseCraftResources(req: Raw): ParsedRecipe['inputs'] {
return asArray<Raw>(req.craftresource)
.map((r) => ({
item: String(r['@uniquename'] ?? ''),
count: num(r['@count']) ?? 0,
maxReturnAmount: num(r['@maxreturnamount']),
}))
.filter((r) => r.item && r.count > 0);
}
function parseRecipesForOutput(
outputItem: string,
craftingrequirements: Raw | Raw[] | undefined,
): ParsedRecipe[] {
const recipes: ParsedRecipe[] = [];
asArray<Raw>(craftingrequirements).forEach((req, i) => {
const inputs = parseCraftResources(req);
if (inputs.length === 0) return; // silver-only / vendor entries are not production recipes
recipes.push({
outputItem,
variant: i,
amountCrafted: num(req['@amountcrafted']) ?? 1,
silverCost: num(req['@silver']) ?? 0,
focusCost: num(req['@craftingfocus']) ?? 0,
source: 'crafting',
inputs,
});
});
return recipes;
}
function collectItem(raw: Raw, items: ParsedItem[], recipes: ParsedRecipe[]): void {
const uniqueName = raw['@uniquename'];
if (!uniqueName || typeof uniqueName !== 'string') return;
const base: ParsedItem = {
uniqueName,
baseName: uniqueName,
enchantment: 0,
tier: num(raw['@tier']),
weight: num(raw['@weight']),
itemValue: num(raw['@itemvalue']),
shopCategory: raw['@shopcategory'] ?? null,
shopSubcategory: raw['@shopsubcategory1'] ?? null,
craftingCategory: raw['@craftingcategory'] ?? null,
};
items.push(base);
recipes.push(...parseRecipesForOutput(uniqueName, raw.craftingrequirements));
// Enchanted variants live under enchantments.enchantment with their own recipes.
for (const ench of asArray<Raw>(raw.enchantments?.enchantment)) {
const level = num(ench['@enchantmentlevel']);
if (!level) continue;
const enchName = `${uniqueName}@${level}`;
items.push({
...base,
uniqueName: enchName,
enchantment: level,
itemValue: num(ench['@itemvalue']) ?? null,
});
recipes.push(...parseRecipesForOutput(enchName, ench.craftingrequirements));
// Upgrade path: previous enchant level + runes/souls/relics -> this level.
const upgrade = ench.upgraderequirements;
if (upgrade) {
const upgradeInputs = parseCraftResources(upgrade);
if (upgradeInputs.length > 0) {
const prev = level === 1 ? uniqueName : `${uniqueName}@${level - 1}`;
recipes.push({
outputItem: enchName,
variant: 0,
amountCrafted: 1,
silverCost: num(upgrade['@silver']) ?? 0,
focusCost: 0,
source: 'upgrade',
inputs: [{ item: prev, count: 1, maxReturnAmount: 0 }, ...upgradeInputs.map((u) => ({ ...u, maxReturnAmount: 0 }))],
});
}
}
}
}
/** Recursively walk the items dump and collect everything that has a @uniquename. */
function walk(node: unknown, items: ParsedItem[], recipes: ParsedRecipe[]): void {
if (Array.isArray(node)) {
for (const child of node) walk(child, items, recipes);
return;
}
if (node && typeof node === 'object') {
const raw = node as Raw;
if (typeof raw['@uniquename'] === 'string') {
collectItem(raw, items, recipes);
return; // nested @uniquename nodes (sockets etc.) are not separate items
}
for (const value of Object.values(raw)) walk(value, items, recipes);
}
}
export function parseGameData(): ParseStats {
const itemsDump = JSON.parse(fs.readFileSync(gamedataFilePath('items.json'), 'utf8'));
const localized = JSON.parse(fs.readFileSync(gamedataFilePath('items_localized.json'), 'utf8')) as Raw[];
const items: ParsedItem[] = [];
const recipes: ParsedRecipe[] = [];
walk(itemsDump.items ?? itemsDump, items, recipes);
const namesEn = new Map<string, string>();
for (const entry of localized) {
const un = entry.UniqueName;
const name = entry.LocalizedNames?.['EN-US'];
if (typeof un === 'string' && typeof name === 'string') namesEn.set(un, name);
}
const db = getDb();
const insertItem = db.prepare(`
INSERT OR REPLACE INTO items
(unique_name, base_name, enchantment, tier, weight, item_value, shop_category, shop_subcategory, crafting_category, name_en)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertRecipe = db.prepare(`
INSERT INTO recipes (output_item, variant, amount_crafted, silver_cost, focus_cost, source)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertInput = db.prepare(`
INSERT OR REPLACE INTO recipe_inputs (recipe_id, input_item, count, max_return_amount)
VALUES (?, ?, ?, ?)
`);
let inputCount = 0;
db.transaction(() => {
db.exec('DELETE FROM recipe_inputs; DELETE FROM recipes; DELETE FROM items;');
for (const it of items) {
// Enchanted names like T4_BAG@1 resolve their display name from the localized dump
// when present; fall back to the base item's name.
const nameEn = namesEn.get(it.uniqueName) ?? namesEn.get(it.baseName) ?? null;
insertItem.run(
it.uniqueName,
it.baseName,
it.enchantment,
it.tier,
it.weight,
it.itemValue,
it.shopCategory,
it.shopSubcategory,
it.craftingCategory,
nameEn,
);
}
for (const r of recipes) {
const { lastInsertRowid } = insertRecipe.run(
r.outputItem,
r.variant,
r.amountCrafted,
r.silverCost,
r.focusCost,
r.source,
);
for (const input of r.inputs) {
insertInput.run(lastInsertRowid, input.item, input.count, input.maxReturnAmount);
inputCount++;
}
}
})();
const stats: ParseStats = {
items: items.length,
recipes: recipes.length,
recipeInputs: inputCount,
localizedNames: namesEn.size,
};
setMeta('gamedata_parsed_at', new Date().toISOString());
setMeta('gamedata_stats', JSON.stringify(stats));
return stats;
}

16
server/src/index.ts Normal file
View file

@ -0,0 +1,16 @@
import Fastify from 'fastify';
import { API_PORT } from './config.js';
import { getDb } from './db/index.js';
import { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js';
const app = Fastify({ logger: { level: 'info' } });
getDb(); // initialize schema on startup
app.get('/api/health', async () => ({ ok: true }));
await app.register(gamedataRoutes);
await app.register(itemRoutes);
await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`);

View file

@ -0,0 +1,24 @@
import type { FastifyInstance } from 'fastify';
import { getDb, getMeta } from '../db/index.js';
import { downloadGameData } from '../gamedata/download.js';
import { parseGameData } from '../gamedata/parse.js';
export async function gamedataRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/gamedata/status', async () => {
const db = getDb();
const count = (table: string) =>
(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
return {
parsedAt: getMeta('gamedata_parsed_at'),
items: count('items'),
recipes: count('recipes'),
};
});
app.post('/api/gamedata/refresh', async (req) => {
const force = (req.query as { force?: string }).force === 'true';
const downloads = await downloadGameData(force);
const stats = parseGameData();
return { downloads, stats };
});
}

View file

@ -0,0 +1,53 @@
import type { FastifyInstance } from 'fastify';
import { getDb } from '../db/index.js';
export async function itemRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/items/search', async (req) => {
const { q = '' } = req.query as { q?: string };
const query = q.trim();
if (!query) return { results: [] };
const results = getDb()
.prepare(
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
FROM items
WHERE name_en LIKE ? OR unique_name LIKE ?
ORDER BY tier, enchantment, unique_name
LIMIT 50`,
)
.all(`%${query}%`, `%${query.toUpperCase()}%`);
return { results };
});
app.get('/api/items/:uniqueName', async (req, reply) => {
const { uniqueName } = req.params as { uniqueName: string };
const db = getDb();
const item = db.prepare('SELECT * FROM items WHERE unique_name = ?').get(uniqueName);
if (!item) return reply.code(404).send({ error: 'item not found' });
const recipes = db
.prepare('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant')
.all(uniqueName) as any[];
for (const r of recipes) {
r.inputs = db
.prepare(
`SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item
WHERE ri.recipe_id = ?`,
)
.all(r.id);
}
const usedIn = db
.prepare(
`SELECT DISTINCT r.output_item, i.name_en
FROM recipe_inputs ri
JOIN recipes r ON r.id = ri.recipe_id
LEFT JOIN items i ON i.unique_name = r.output_item
WHERE ri.input_item = ?
LIMIT 100`,
)
.all(uniqueName);
return { item, recipes, usedIn };
});
}

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src"]
}

12
web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Albion Economy Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
web/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "@albion/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
}
}

31
web/src/App.tsx Normal file
View file

@ -0,0 +1,31 @@
import { useState } from 'react';
import ItemsPage from './pages/ItemsPage';
import GameDataPage from './pages/GameDataPage';
const PAGES = [
{ id: 'items', label: 'Item Browser', component: ItemsPage },
{ id: 'gamedata', label: 'Game Data', component: GameDataPage },
] as const;
export default function App() {
const [page, setPage] = useState<string>('items');
const Active = PAGES.find((p) => p.id === page)?.component ?? ItemsPage;
return (
<div className="app">
<aside className="sidebar">
<h1>Albion Economy</h1>
<nav>
{PAGES.map((p) => (
<button key={p.id} className={page === p.id ? 'active' : ''} onClick={() => setPage(p.id)}>
{p.label}
</button>
))}
</nav>
</aside>
<main className="main">
<Active />
</main>
</div>
);
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
interface Status {
parsedAt: string | null;
items: number;
recipes: number;
}
export default function GameDataPage() {
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const load = () => fetch('/api/gamedata/status').then((r) => r.json()).then(setStatus);
useEffect(() => {
load();
}, []);
const refresh = async (force: boolean) => {
setBusy(true);
setMessage(force ? 'Re-downloading game data (large files, this can take a few minutes)...' : 'Updating...');
try {
const res = await fetch(`/api/gamedata/refresh?force=${force}`, { method: 'POST' });
const data = await res.json();
setMessage(`Done: ${data.stats.items} items, ${data.stats.recipes} recipes.`);
await load();
} catch (e) {
setMessage(`Failed: ${e}`);
} finally {
setBusy(false);
}
};
return (
<div>
<h2>Game Data</h2>
<div className="card">
{status ? (
<p>
{status.items} items · {status.recipes} recipes · last parsed:{' '}
{status.parsedAt ? new Date(status.parsedAt).toLocaleString() : 'never'}
</p>
) : (
<p className="muted">Loading…</p>
)}
<p className="muted">
Game data comes from the community ao-bin-dumps repository. Re-download after each Albion patch.
</p>
<button className="primary" disabled={busy} onClick={() => refresh(true)}>
Re-download game data
</button>
{message && <p>{message}</p>}
</div>
</div>
);
}

133
web/src/pages/ItemsPage.tsx Normal file
View file

@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
interface SearchResult {
unique_name: string;
name_en: string | null;
tier: number | null;
enchantment: number;
shop_category: string | null;
shop_subcategory: string | null;
}
interface RecipeInput {
input_item: string;
count: number;
max_return_amount: number | null;
name_en: string | null;
}
interface Recipe {
id: number;
source: string;
variant: number;
amount_crafted: number;
silver_cost: number;
focus_cost: number;
inputs: RecipeInput[];
}
interface ItemDetail {
item: SearchResult & { weight: number | null; item_value: number | null; crafting_category: string | null };
recipes: Recipe[];
usedIn: { output_item: string; name_en: string | null }[];
}
export default function ItemsPage() {
const [q, setQ] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [detail, setDetail] = useState<ItemDetail | null>(null);
useEffect(() => {
if (!q.trim()) {
setResults([]);
return;
}
const t = setTimeout(() => {
fetch(`/api/items/search?q=${encodeURIComponent(q)}`)
.then((r) => r.json())
.then((d) => setResults(d.results ?? []));
}, 250);
return () => clearTimeout(t);
}, [q]);
const open = (uniqueName: string) => {
fetch(`/api/items/${encodeURIComponent(uniqueName)}`)
.then((r) => r.json())
.then(setDetail);
};
return (
<div>
<h2>Item Browser</h2>
<div className="card">
<input
type="text"
placeholder="Search items (e.g. bag, T4_BAG, claymore)..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{results.length > 0 && (
<table style={{ marginTop: 12 }}>
<thead>
<tr><th>Name</th><th>ID</th><th>Tier</th><th>Category</th></tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.unique_name} className="clickable" onClick={() => open(r.unique_name)}>
<td>
{r.name_en ?? <span className="muted">(unnamed)</span>}
{r.enchantment > 0 && <span className="tag">.{r.enchantment}</span>}
</td>
<td className="muted">{r.unique_name}</td>
<td>{r.tier ?? '—'}</td>
<td className="muted">{r.shop_subcategory ?? r.shop_category ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{detail && (
<div className="card">
<h3>
{detail.item.name_en ?? detail.item.unique_name}
<span className="tag">{detail.item.unique_name}</span>
</h3>
<p className="muted">
Tier {detail.item.tier ?? '—'} · weight {detail.item.weight ?? '—'} · item value{' '}
{detail.item.item_value ?? '—'} · {detail.item.crafting_category ?? 'no crafting category'}
</p>
{detail.recipes.length === 0 && <p className="muted">No crafting recipe (gathered, dropped, or vendor item).</p>}
{detail.recipes.map((r) => (
<div key={r.id} style={{ marginBottom: 12 }}>
<strong>
{r.source === 'upgrade' ? 'Enchanting (upgrade)' : 'Crafting'} recipe
{r.variant > 0 ? ` (alt ${r.variant})` : ''}
</strong>
<span className="tag">focus {r.focus_cost}</span>
{r.amount_crafted !== 1 && <span className="tag">crafts {r.amount_crafted}</span>}
<ul>
{r.inputs.map((inp) => (
<li key={inp.input_item}>
{inp.count}× {inp.name_en ?? inp.input_item}{' '}
{inp.max_return_amount === 0 && <span className="tag">no return</span>}
</li>
))}
</ul>
</div>
))}
{detail.usedIn.length > 0 && (
<>
<strong>Used in</strong>
<p className="muted" style={{ marginTop: 4 }}>
{detail.usedIn.slice(0, 20).map((u) => u.name_en ?? u.output_item).join(', ')}
{detail.usedIn.length > 20 ? ` … +${detail.usedIn.length - 20} more` : ''}
</p>
</>
)}
</div>
)}
</div>
);
}

92
web/src/styles.css Normal file
View file

@ -0,0 +1,92 @@
:root {
color-scheme: dark;
--bg: #16181d;
--panel: #1f232b;
--border: #333a46;
--text: #e6e9ef;
--muted: #9aa3b2;
--accent: #d4a94e; /* albion gold */
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: 15px;
}
.app { display: flex; min-height: 100vh; }
.sidebar {
width: 210px;
flex-shrink: 0;
background: var(--panel);
border-right: 1px solid var(--border);
padding: 16px 10px;
}
.sidebar h1 { font-size: 17px; color: var(--accent); margin: 4px 8px 18px; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar nav button {
text-align: left;
background: none;
border: none;
color: var(--muted);
padding: 9px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.sidebar nav button:hover { background: #262c36; color: var(--text); }
.sidebar nav button.active { background: #2b323e; color: var(--accent); font-weight: 600; }
.main { flex: 1; padding: 24px 28px; min-width: 0; }
.main h2 { margin-top: 0; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
input[type="text"], input[type="number"], select {
background: #12151a;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
}
input[type="text"] { width: 320px; }
button.primary {
background: var(--accent);
color: #1a1508;
border: none;
border-radius: 6px;
padding: 8px 14px;
font-weight: 600;
cursor: pointer;
}
button.primary:disabled { opacity: 0.5; cursor: default; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
tr.clickable { cursor: pointer; }
tr.clickable:hover { background: #262c36; }
.muted { color: var(--muted); }
.tag {
display: inline-block;
background: #2b323e;
border-radius: 4px;
padding: 1px 7px;
font-size: 12px;
color: var(--muted);
margin-left: 6px;
}

15
web/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src"]
}

12
web/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:4310',
},
},
});