Phase 4: character profiles

Destiny-board profiles (mastery/spec/other-specs per crafting category),
premium flag, focus efficiency formula (250/30/30 per level), active
profile feeds tax + per-category focus costs into the ranker; editor UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 03:11:01 +05:00
parent 9aebed4f55
commit 3b20b1e431
9 changed files with 384 additions and 11 deletions

View file

@ -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<string, CategoryProgress>; // 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 }[];
}

View file

@ -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 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 ( CREATE TABLE IF NOT EXISTS price_overrides (
item TEXT NOT NULL, item TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,

View file

@ -1,4 +1,5 @@
import { getDb } from '../db/index.js'; import { getDb } from '../db/index.js';
import { focusEfficiencyFor, getActiveProfile } from '../character/service.js';
import { getEconomyConfig } from './config.js'; import { getEconomyConfig } from './config.js';
import { import {
computeOpportunity, computeOpportunity,
@ -105,14 +106,22 @@ export interface RankedOpportunity extends Opportunity {
inputNames: Record<string, string>; inputNames: Record<string, string>;
} }
export function rankOpportunities(req: RankRequest): RankedOpportunity[] { export interface RankResult {
opportunities: RankedOpportunity[];
profileName: string | null;
}
export function rankOpportunities(req: RankRequest): RankResult {
const cfg = getEconomyConfig(); const cfg = getEconomyConfig();
const provider = buildProvider(req.city); 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 = { const opts: CalcOptions = {
city: req.city, city: req.city,
useFocus: req.useFocus ?? false, useFocus: req.useFocus ?? false,
focusEfficiency: req.focusEfficiency ?? 0, focusEfficiency: req.focusEfficiency ?? 0,
premium: req.premium ?? true, premium: req.premium ?? profile?.premium ?? true,
stationFeePer100: req.stationFeePer100 ?? cfg.defaultStationFeePer100, stationFeePer100: req.stationFeePer100 ?? cfg.defaultStationFeePer100,
sellMode: req.sellMode ?? 'order', sellMode: req.sellMode ?? 'order',
maxCraftDepth: req.maxCraftDepth ?? 2, 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); const recipe = provider.recipesFor(c.output_item).find((r) => r.id === c.id);
if (!recipe) continue; 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; if (!opp) continue;
results.push({ results.push({
...opp, ...opp,
@ -165,5 +178,5 @@ export function rankOpportunities(req: RankRequest): RankedOpportunity[] {
if (it?.name_en) r.inputNames[inp.item] = it.name_en; if (it?.name_en) r.inputNames[inp.item] = it.name_en;
} }
} }
return top; return { opportunities: top, profileName: profile?.name ?? null };
} }

View file

@ -5,6 +5,7 @@ import { gamedataRoutes } from './routes/gamedata.js';
import { itemRoutes } from './routes/items.js'; import { itemRoutes } from './routes/items.js';
import { marketRoutes } from './routes/market.js'; import { marketRoutes } from './routes/market.js';
import { economyRoutes } from './routes/economy.js'; import { economyRoutes } from './routes/economy.js';
import { profileRoutes } from './routes/profiles.js';
const app = Fastify({ logger: { level: 'info' } }); const app = Fastify({ logger: { level: 'info' } });
@ -15,6 +16,7 @@ await app.register(gamedataRoutes);
await app.register(itemRoutes); await app.register(itemRoutes);
await app.register(marketRoutes); await app.register(marketRoutes);
await app.register(economyRoutes); await app.register(economyRoutes);
await app.register(profileRoutes);
await app.listen({ port: API_PORT, host: '127.0.0.1' }); await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`); console.log(`API listening on http://127.0.0.1:${API_PORT}`);

View file

@ -14,7 +14,7 @@ export async function economyRoutes(app: FastifyInstance): Promise<void> {
const body = (req.body ?? {}) as RankRequest; const body = (req.body ?? {}) as RankRequest;
if (!body.city) return reply.code(400).send({ error: 'city is required' }); if (!body.city) return reply.code(400).send({ error: 'city is required' });
const started = Date.now(); const started = Date.now();
const opportunities = rankOpportunities(body); const result = rankOpportunities(body);
return { opportunities, tookMs: Date.now() - started }; return { ...result, tookMs: Date.now() - started };
}); });
} }

View file

@ -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<void> {
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 };
});
}

View file

@ -4,9 +4,11 @@ import GameDataPage from './pages/GameDataPage';
import MarketPage from './pages/MarketPage'; import MarketPage from './pages/MarketPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import OpportunitiesPage from './pages/OpportunitiesPage'; import OpportunitiesPage from './pages/OpportunitiesPage';
import CharacterPage from './pages/CharacterPage';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'character', label: 'Character', component: CharacterPage },
{ id: 'market', label: 'Market Data', component: MarketPage }, { id: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'items', label: 'Item Browser', component: ItemsPage },
{ id: 'gamedata', label: 'Game Data', component: GameDataPage }, { id: 'gamedata', label: 'Game Data', component: GameDataPage },

View file

@ -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<string, CategoryProgress> };
updatedAt: string;
}
interface CategoryInfo {
category: string;
items: number;
}
const CATEGORY_LABELS: Record<string, string> = {
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<Profile[]>([]);
const [categories, setCategories] = useState<CategoryInfo[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [draft, setDraft] = useState<Profile | null>(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 (
<div>
<h2>Character</h2>
<div className="card">
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
Profile{' '}
<select value={selectedId ?? ''} onChange={(e) => setSelectedId(Number(e.target.value))}>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.isActive ? '(active)' : ''}
</option>
))}
</select>
</label>
<input type="text" placeholder="New profile name…" value={newName} style={{ width: 180 }}
onChange={(e) => setNewName(e.target.value)} />
<button onClick={create}>Create</button>
{draft && !draft.isActive && (
<button className="primary" onClick={activate}>Make active</button>
)}
</div>
<p className="muted" style={{ marginBottom: 0 }}>
The <strong>active</strong> 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.
</p>
</div>
{draft && (
<div className="card">
<div style={{ display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
Name <input type="text" value={draft.name} style={{ width: 160 }}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</label>
<label>
<input type="checkbox" checked={draft.premium}
onChange={(e) => setDraft({ ...draft, premium: e.target.checked })} /> premium (4% sales tax instead of 8%)
</label>
<button className="primary" onClick={save}>Save profile</button>
{saved && <span style={{ color: '#7fd18a' }}>Saved.</span>}
</div>
<h3>Destiny board — crafting &amp; refining</h3>
<p className="muted">
For each category you craft: <em>mastery</em> = the node's mastery level; <em>spec</em> = your
specialization level in the item line you actually craft; <em>other specs</em> = the sum of your remaining
specialization levels in that node. Efficiency halves focus cost per 10,000 points.
</p>
<input type="text" placeholder="Filter categories…" value={filter} onChange={(e) => setFilter(e.target.value)} />
<table style={{ marginTop: 10 }}>
<thead>
<tr>
<th>Category</th><th>Mastery</th><th>Spec</th><th>Other specs (sum)</th><th>Efficiency</th><th>Focus cost</th>
</tr>
</thead>
<tbody>
{visibleCategories.map((c) => {
const p = draft.data.categories[c.category];
const eff = efficiency(p);
return (
<tr key={c.category}>
<td>
{CATEGORY_LABELS[c.category] ?? c.category}
<span className="tag">{c.items} items</span>
</td>
<td><input type="number" min={0} max={100} value={p?.mastery ?? 0} style={{ width: 70 }}
onChange={(e) => setProgress(c.category, 'mastery', Number(e.target.value))} /></td>
<td><input type="number" min={0} max={120} value={p?.spec ?? 0} style={{ width: 70 }}
onChange={(e) => setProgress(c.category, 'spec', Number(e.target.value))} /></td>
<td><input type="number" min={0} value={p?.otherSpecs ?? 0} style={{ width: 70 }}
onChange={(e) => setProgress(c.category, 'otherSpecs', Number(e.target.value))} /></td>
<td>{eff.toLocaleString()}</td>
<td>{eff > 0 ? `${(Math.pow(0.5, eff / 10000) * 100).toFixed(1)}%` : '100%'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -55,7 +55,8 @@ export default function OpportunitiesPage() {
const [premium, setPremium] = useState(true); const [premium, setPremium] = useState(true);
const [sellMode, setSellMode] = useState<'order' | 'instant'>('order'); const [sellMode, setSellMode] = useState<'order' | 'instant'>('order');
const [stationFee, setStationFee] = useState(400); const [stationFee, setStationFee] = useState(400);
const [focusEff, setFocusEff] = useState(0); const [focusEff, setFocusEff] = useState(''); // empty = use the active character profile
const [profileName, setProfileName] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit'); const [sortBy, setSortBy] = useState<'profit' | 'roi' | 'focus'>('profit');
const [opps, setOpps] = useState<Opp[]>([]); const [opps, setOpps] = useState<Opp[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -77,12 +78,13 @@ export default function OpportunitiesPage() {
premium, premium,
sellMode, sellMode,
stationFeePer100: stationFee, stationFeePer100: stationFee,
focusEfficiency: focusEff, ...(focusEff !== '' ? { focusEfficiency: Number(focusEff) } : {}),
limit: 200, limit: 200,
}), }),
}); });
const data = await res.json(); const data = await res.json();
setOpps(data.opportunities ?? []); setOpps(data.opportunities ?? []);
setProfileName(data.profileName ?? null);
setRanAt(new Date().toLocaleTimeString()); setRanAt(new Date().toLocaleTimeString());
} finally { } finally {
setLoading(false); setLoading(false);
@ -118,14 +120,19 @@ export default function OpportunitiesPage() {
</select></label> </select></label>
<label><input type="checkbox" checked={useFocus} onChange={(e) => setUseFocus(e.target.checked)} /> use focus</label> <label><input type="checkbox" checked={useFocus} onChange={(e) => setUseFocus(e.target.checked)} /> use focus</label>
{useFocus && ( {useFocus && (
<label>focus efficiency <input type="number" value={focusEff} style={{ width: 90 }} <label>focus efficiency <input type="number" placeholder="from profile" value={focusEff} style={{ width: 110 }}
onChange={(e) => setFocusEff(Number(e.target.value))} /></label> onChange={(e) => setFocusEff(e.target.value)} /></label>
)} )}
<label><input type="checkbox" checked={premium} onChange={(e) => setPremium(e.target.checked)} /> premium</label> <label><input type="checkbox" checked={premium} onChange={(e) => setPremium(e.target.checked)} /> premium</label>
<label>station fee <input type="number" value={stationFee} style={{ width: 80 }} <label>station fee <input type="number" value={stationFee} style={{ width: 80 }}
onChange={(e) => setStationFee(Number(e.target.value))} /></label> onChange={(e) => setStationFee(Number(e.target.value))} /></label>
<button className="primary" disabled={loading} onClick={run}>{loading ? 'Computing…' : 'Compute'}</button> <button className="primary" disabled={loading} onClick={run}>{loading ? 'Computing…' : 'Compute'}</button>
{ranAt && <span className="muted">as of {ranAt}</span>} {ranAt && (
<span className="muted">
as of {ranAt}
{profileName ? ` · profile: ${profileName}` : ' · no character profile set'}
</span>
)}
</div> </div>
</div> </div>