diff --git a/server/src/character/service.ts b/server/src/character/service.ts new file mode 100644 index 0000000..1a3ae62 --- /dev/null +++ b/server/src/character/service.ts @@ -0,0 +1,107 @@ +import { getDb } from '../db/index.js'; + +/** + * Character profile: the user's destiny board progress, entered manually + * (Phase 7 adds sniffer auto-fill). Focus efficiency per the live mechanic: + * each specialization level gives +250 efficiency for that item line, + * masteries and sibling specializations in the same node give +30 each. + */ +export interface CategoryProgress { + /** node mastery level (0-100) */ + mastery: number; + /** specialization level of the item line you actually craft (0-120) */ + spec: number; + /** sum of your OTHER specialization levels in the same node */ + otherSpecs: number; +} + +export interface ProfileData { + categories: Record; // keyed by crafting_category +} + +export interface Profile { + id: number; + name: string; + isActive: boolean; + premium: boolean; + data: ProfileData; + updatedAt: string; +} + +const EFF_PER_SPEC = 250; +const EFF_PER_MASTERY = 30; +const EFF_PER_OTHER_SPEC = 30; + +export function focusEfficiencyFor(profile: Profile | null, craftingCategory: string | null): number { + if (!profile || !craftingCategory) return 0; + const p = profile.data.categories?.[craftingCategory]; + if (!p) return 0; + return EFF_PER_MASTERY * (p.mastery || 0) + EFF_PER_SPEC * (p.spec || 0) + EFF_PER_OTHER_SPEC * (p.otherSpecs || 0); +} + +function rowToProfile(r: any): Profile { + let data: ProfileData = { categories: {} }; + try { + data = { categories: {}, ...JSON.parse(r.data) }; + } catch { + /* keep empty */ + } + return { id: r.id, name: r.name, isActive: !!r.is_active, premium: !!r.premium, data, updatedAt: r.updated_at }; +} + +export function listProfiles(): Profile[] { + return (getDb().prepare('SELECT * FROM profiles ORDER BY id').all() as any[]).map(rowToProfile); +} + +export function getActiveProfile(): Profile | null { + const row = getDb().prepare('SELECT * FROM profiles WHERE is_active = 1 LIMIT 1').get(); + return row ? rowToProfile(row) : null; +} + +export function createProfile(name: string): Profile { + const db = getDb(); + const { lastInsertRowid } = db + .prepare('INSERT INTO profiles (name, is_active, premium, data, updated_at) VALUES (?, 0, 1, ?, ?)') + .run(name, '{}', new Date().toISOString()); + const hasActive = db.prepare('SELECT COUNT(*) AS n FROM profiles WHERE is_active = 1').get() as { n: number }; + if (hasActive.n === 0) db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(lastInsertRowid); + return rowToProfile(db.prepare('SELECT * FROM profiles WHERE id = ?').get(lastInsertRowid)); +} + +export function updateProfile(id: number, patch: { name?: string; premium?: boolean; data?: ProfileData }): Profile | null { + const db = getDb(); + const row = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any; + if (!row) return null; + db.prepare('UPDATE profiles SET name = ?, premium = ?, data = ?, updated_at = ? WHERE id = ?').run( + patch.name ?? row.name, + patch.premium != null ? (patch.premium ? 1 : 0) : row.premium, + patch.data ? JSON.stringify(patch.data) : row.data, + new Date().toISOString(), + id, + ); + return rowToProfile(db.prepare('SELECT * FROM profiles WHERE id = ?').get(id)); +} + +export function activateProfile(id: number): void { + const db = getDb(); + db.transaction(() => { + db.prepare('UPDATE profiles SET is_active = 0').run(); + db.prepare('UPDATE profiles SET is_active = 1 WHERE id = ?').run(id); + })(); +} + +export function deleteProfile(id: number): void { + getDb().prepare('DELETE FROM profiles WHERE id = ?').run(id); +} + +/** Crafting categories that actually have recipes, for the profile editor UI. */ +export function craftableCategories(): { category: string; items: number }[] { + return getDb() + .prepare( + `SELECT i.crafting_category AS category, COUNT(DISTINCT r.output_item) AS items + FROM recipes r JOIN items i ON i.unique_name = r.output_item + WHERE i.crafting_category IS NOT NULL + GROUP BY i.crafting_category ORDER BY items DESC`, + ) + .all() as { category: string; items: number }[]; +} diff --git a/server/src/db/index.ts b/server/src/db/index.ts index f2fd928..f5625fb 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -57,6 +57,15 @@ CREATE TABLE IF NOT EXISTS market_prices ( ); CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city); +CREATE TABLE IF NOT EXISTS profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 0, + premium INTEGER NOT NULL DEFAULT 1, + data TEXT NOT NULL DEFAULT '{}', -- JSON: destiny board levels per crafting category + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS price_overrides ( item TEXT NOT NULL, city TEXT NOT NULL, diff --git a/server/src/economy/service.ts b/server/src/economy/service.ts index e8ecf45..6824da5 100644 --- a/server/src/economy/service.ts +++ b/server/src/economy/service.ts @@ -1,4 +1,5 @@ import { getDb } from '../db/index.js'; +import { focusEfficiencyFor, getActiveProfile } from '../character/service.js'; import { getEconomyConfig } from './config.js'; import { computeOpportunity, @@ -105,14 +106,22 @@ export interface RankedOpportunity extends Opportunity { inputNames: Record; } -export function rankOpportunities(req: RankRequest): RankedOpportunity[] { +export interface RankResult { + opportunities: RankedOpportunity[]; + profileName: string | null; +} + +export function rankOpportunities(req: RankRequest): RankResult { const cfg = getEconomyConfig(); const provider = buildProvider(req.city); + // The active character profile supplies premium status and per-category focus + // efficiency unless the request overrides them explicitly. + const profile = getActiveProfile(); const opts: CalcOptions = { city: req.city, useFocus: req.useFocus ?? false, focusEfficiency: req.focusEfficiency ?? 0, - premium: req.premium ?? true, + premium: req.premium ?? profile?.premium ?? true, stationFeePer100: req.stationFeePer100 ?? cfg.defaultStationFeePer100, sellMode: req.sellMode ?? 'order', maxCraftDepth: req.maxCraftDepth ?? 2, @@ -141,7 +150,11 @@ export function rankOpportunities(req: RankRequest): RankedOpportunity[] { const recipe = provider.recipesFor(c.output_item).find((r) => r.id === c.id); if (!recipe) continue; - const opp = computeOpportunity(provider, cfg, recipe, opts, cache); + const candidateOpts = + req.focusEfficiency == null && profile + ? { ...opts, focusEfficiency: focusEfficiencyFor(profile, c.crafting_category) } + : opts; + const opp = computeOpportunity(provider, cfg, recipe, candidateOpts, cache); if (!opp) continue; results.push({ ...opp, @@ -165,5 +178,5 @@ export function rankOpportunities(req: RankRequest): RankedOpportunity[] { if (it?.name_en) r.inputNames[inp.item] = it.name_en; } } - return top; + return { opportunities: top, profileName: profile?.name ?? null }; } diff --git a/server/src/index.ts b/server/src/index.ts index 89eef91..a443bbd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import { gamedataRoutes } from './routes/gamedata.js'; import { itemRoutes } from './routes/items.js'; import { marketRoutes } from './routes/market.js'; import { economyRoutes } from './routes/economy.js'; +import { profileRoutes } from './routes/profiles.js'; const app = Fastify({ logger: { level: 'info' } }); @@ -15,6 +16,7 @@ await app.register(gamedataRoutes); await app.register(itemRoutes); await app.register(marketRoutes); await app.register(economyRoutes); +await app.register(profileRoutes); await app.listen({ port: API_PORT, host: '127.0.0.1' }); console.log(`API listening on http://127.0.0.1:${API_PORT}`); diff --git a/server/src/routes/economy.ts b/server/src/routes/economy.ts index 546c9f0..4a52aef 100644 --- a/server/src/routes/economy.ts +++ b/server/src/routes/economy.ts @@ -14,7 +14,7 @@ export async function economyRoutes(app: FastifyInstance): Promise { const body = (req.body ?? {}) as RankRequest; if (!body.city) return reply.code(400).send({ error: 'city is required' }); const started = Date.now(); - const opportunities = rankOpportunities(body); - return { opportunities, tookMs: Date.now() - started }; + const result = rankOpportunities(body); + return { ...result, tookMs: Date.now() - started }; }); } diff --git a/server/src/routes/profiles.ts b/server/src/routes/profiles.ts new file mode 100644 index 0000000..b2e36ef --- /dev/null +++ b/server/src/routes/profiles.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify'; +import { + activateProfile, + craftableCategories, + createProfile, + deleteProfile, + listProfiles, + updateProfile, + type ProfileData, +} from '../character/service.js'; + +export async function profileRoutes(app: FastifyInstance): Promise { + app.get('/api/profiles', async () => ({ + profiles: listProfiles(), + categories: craftableCategories(), + })); + + app.post('/api/profiles', async (req, reply) => { + const { name } = (req.body ?? {}) as { name?: string }; + if (!name?.trim()) return reply.code(400).send({ error: 'name is required' }); + return { profile: createProfile(name.trim()) }; + }); + + app.put('/api/profiles/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id); + const body = (req.body ?? {}) as { name?: string; premium?: boolean; data?: ProfileData }; + const profile = updateProfile(id, body); + if (!profile) return reply.code(404).send({ error: 'profile not found' }); + return { profile }; + }); + + app.post('/api/profiles/:id/activate', async (req) => { + activateProfile(Number((req.params as { id: string }).id)); + return { ok: true }; + }); + + app.delete('/api/profiles/:id', async (req) => { + deleteProfile(Number((req.params as { id: string }).id)); + return { ok: true }; + }); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 35b9673..63eb8e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,9 +4,11 @@ import GameDataPage from './pages/GameDataPage'; import MarketPage from './pages/MarketPage'; import SettingsPage from './pages/SettingsPage'; import OpportunitiesPage from './pages/OpportunitiesPage'; +import CharacterPage from './pages/CharacterPage'; const PAGES = [ { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, + { id: 'character', label: 'Character', component: CharacterPage }, { id: 'market', label: 'Market Data', component: MarketPage }, { id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'gamedata', label: 'Game Data', component: GameDataPage }, diff --git a/web/src/pages/CharacterPage.tsx b/web/src/pages/CharacterPage.tsx new file mode 100644 index 0000000..3131e1e --- /dev/null +++ b/web/src/pages/CharacterPage.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useState } from 'react'; + +interface CategoryProgress { + mastery: number; + spec: number; + otherSpecs: number; +} + +interface Profile { + id: number; + name: string; + isActive: boolean; + premium: boolean; + data: { categories: Record }; + updatedAt: string; +} + +interface CategoryInfo { + category: string; + items: number; +} + +const CATEGORY_LABELS: Record = { + wood: 'Wood refining (planks)', + ore: 'Ore refining (bars)', + hide: 'Hide refining (leather)', + fiber: 'Fiber refining (cloth)', + rock: 'Stone refining (blocks)', +}; + +function efficiency(p: CategoryProgress | undefined): number { + if (!p) return 0; + return 30 * (p.mastery || 0) + 250 * (p.spec || 0) + 30 * (p.otherSpecs || 0); +} + +export default function CharacterPage() { + const [profiles, setProfiles] = useState([]); + const [categories, setCategories] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [newName, setNewName] = useState(''); + const [filter, setFilter] = useState(''); + const [saved, setSaved] = useState(false); + + const load = async () => { + const d = await fetch('/api/profiles').then((r) => r.json()); + setProfiles(d.profiles); + setCategories(d.categories); + if (d.profiles.length > 0) { + const active = d.profiles.find((p: Profile) => p.isActive) ?? d.profiles[0]; + setSelectedId((prev) => prev ?? active.id); + } + }; + + useEffect(() => { + load(); + }, []); + + useEffect(() => { + const p = profiles.find((x) => x.id === selectedId); + setDraft(p ? JSON.parse(JSON.stringify(p)) : null); + }, [selectedId, profiles]); + + const create = async () => { + if (!newName.trim()) return; + const d = await fetch('/api/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName.trim() }), + }).then((r) => r.json()); + setNewName(''); + await load(); + setSelectedId(d.profile.id); + }; + + const save = async () => { + if (!draft) return; + await fetch(`/api/profiles/${draft.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: draft.name, premium: draft.premium, data: draft.data }), + }); + await load(); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + const activate = async () => { + if (!draft) return; + await fetch(`/api/profiles/${draft.id}/activate`, { method: 'POST' }); + await load(); + }; + + const setProgress = (cat: string, field: keyof CategoryProgress, value: number) => { + if (!draft) return; + const cur = draft.data.categories[cat] ?? { mastery: 0, spec: 0, otherSpecs: 0 }; + setDraft({ + ...draft, + data: { categories: { ...draft.data.categories, [cat]: { ...cur, [field]: value } } }, + }); + }; + + const visibleCategories = useMemo( + () => categories.filter((c) => !filter || c.category.includes(filter.toLowerCase())), + [categories, filter], + ); + + return ( +
+

Character

+
+
+ + setNewName(e.target.value)} /> + + {draft && !draft.isActive && ( + + )} +
+

+ The active profile feeds every calculation: premium status sets your market tax, and your + destiny board levels set focus costs per category. Until the packet sniffer (later phase) auto-fills this, + enter the levels from your in-game destiny board. +

+
+ + {draft && ( +
+
+ + + + {saved && Saved.} +
+ +

Destiny board — crafting & refining

+

+ For each category you craft: mastery = the node's mastery level; spec = your + specialization level in the item line you actually craft; other specs = the sum of your remaining + specialization levels in that node. Efficiency halves focus cost per 10,000 points. +

+ setFilter(e.target.value)} /> + + + + + + + + {visibleCategories.map((c) => { + const p = draft.data.categories[c.category]; + const eff = efficiency(p); + return ( + + + + + + + + + ); + })} + +
CategoryMasterySpecOther specs (sum)EfficiencyFocus cost
+ {CATEGORY_LABELS[c.category] ?? c.category} + {c.items} items + setProgress(c.category, 'mastery', Number(e.target.value))} /> setProgress(c.category, 'spec', Number(e.target.value))} /> setProgress(c.category, 'otherSpecs', Number(e.target.value))} />{eff.toLocaleString()}{eff > 0 ? `${(Math.pow(0.5, eff / 10000) * 100).toFixed(1)}%` : '100%'}
+
+ )} +
+ ); +} diff --git a/web/src/pages/OpportunitiesPage.tsx b/web/src/pages/OpportunitiesPage.tsx index 4f3181d..434f53c 100644 --- a/web/src/pages/OpportunitiesPage.tsx +++ b/web/src/pages/OpportunitiesPage.tsx @@ -55,7 +55,8 @@ export default function OpportunitiesPage() { const [premium, setPremium] = useState(true); const [sellMode, setSellMode] = useState<'order' | 'instant'>('order'); const [stationFee, setStationFee] = useState(400); - const [focusEff, setFocusEff] = useState(0); + const [focusEff, setFocusEff] = useState(''); // empty = use the active character profile + const [profileName, setProfileName] = useState(null); const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit'); const [opps, setOpps] = useState([]); const [loading, setLoading] = useState(false); @@ -77,12 +78,13 @@ export default function OpportunitiesPage() { premium, sellMode, stationFeePer100: stationFee, - focusEfficiency: focusEff, + ...(focusEff !== '' ? { focusEfficiency: Number(focusEff) } : {}), limit: 200, }), }); const data = await res.json(); setOpps(data.opportunities ?? []); + setProfileName(data.profileName ?? null); setRanAt(new Date().toLocaleTimeString()); } finally { setLoading(false); @@ -118,14 +120,19 @@ export default function OpportunitiesPage() { {useFocus && ( - + )} - {ranAt && as of {ranAt}} + {ranAt && ( + + as of {ranAt} + {profileName ? ` · profile: ${profileName}` : ' · no character profile set'} + + )}