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:
parent
9aebed4f55
commit
3b20b1e431
9 changed files with 384 additions and 11 deletions
107
server/src/character/service.ts
Normal file
107
server/src/character/service.ts
Normal 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 }[];
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
server/src/routes/profiles.ts
Normal file
41
server/src/routes/profiles.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
192
web/src/pages/CharacterPage.tsx
Normal file
192
web/src/pages/CharacterPage.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue