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