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:
commit
7ba275dd77
22 changed files with 4703 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
server/data/
|
||||
*.log
|
||||
.env
|
||||
3758
package-lock.json
generated
Normal file
3758
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
24
server/package.json
Normal 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
21
server/src/config.ts
Normal 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
72
server/src/db/index.ts
Normal 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);
|
||||
}
|
||||
35
server/src/gamedata/cli.ts
Normal file
35
server/src/gamedata/cli.ts
Normal 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;
|
||||
}
|
||||
51
server/src/gamedata/download.ts
Normal file
51
server/src/gamedata/download.ts
Normal 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;
|
||||
}
|
||||
225
server/src/gamedata/parse.ts
Normal file
225
server/src/gamedata/parse.ts
Normal 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
16
server/src/index.ts
Normal 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}`);
|
||||
24
server/src/routes/gamedata.ts
Normal file
24
server/src/routes/gamedata.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
53
server/src/routes/items.ts
Normal file
53
server/src/routes/items.ts
Normal 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
16
server/tsconfig.json
Normal 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
12
web/index.html
Normal 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
22
web/package.json
Normal 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
31
web/src/App.tsx
Normal 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
10
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
56
web/src/pages/GameDataPage.tsx
Normal file
56
web/src/pages/GameDataPage.tsx
Normal 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
133
web/src/pages/ItemsPage.tsx
Normal 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
92
web/src/styles.css
Normal 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
15
web/tsconfig.json
Normal 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
12
web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue