This commit is contained in:
gpatruno
2026-07-18 17:25:56 +02:00
parent 2614d679c8
commit 2168998679
26 changed files with 2760 additions and 894 deletions
+230 -80
View File
@@ -3,17 +3,22 @@ import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
const CHOICE_LABELS = {
chance: 'Chance',
gain: 'Gain',
key: 'Key progress',
};
function formatSkillValue(skill, value) {
const prefix = skill.prefix || '';
if (skill.unit === 'cr-cents') {
return `${prefix}${formatCredits(value)} cr`.replace(/^\+\+/, '+');
}
if (skill.unit === '%') return `${prefix || '+'}${value}%`;
if (skill.unit === 's') return `${prefix || ''}${value}s`;
if (skill.unit === ' osu') return `${prefix || '+'}${value} osu`;
if (skill.prefix === '') return `${value}`;
if (value === 0 && !prefix) return `${value}`;
return `${prefix || '+'}${value}`;
}
export default function PrestigePage() {
const { user, loading, patchUser, refresh } = useAuth();
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
const [confirmText, setConfirmText] = useState('');
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
@@ -49,22 +54,21 @@ export default function PrestigePage() {
const cost = data?.prestige?.costCents ?? 0;
const p = data?.prestige;
const unlockAt = data?.prestige?.visibleAtCents ?? 0;
const prBalance = p?.prBalance ?? user.prBalance ?? 0;
const canClick = Boolean(data?.canPrestige) && !busy;
const onPrestige = async () => {
if (!selected || confirmText !== 'PRESTIGE') return;
if (!canClick) return;
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.doPrestige(selected);
const result = await api.doPrestige();
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.`
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
);
setSelected(null);
setConfirmText('');
await load();
} catch (err) {
setError(err.message);
@@ -73,28 +77,90 @@ export default function PrestigePage() {
}
};
const onUpgrade = async (skillId) => {
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.prestigeUpgrade(skillId);
patchUser(result.user);
setMessage(`Upgraded ${skillId}.`);
setData((prev) =>
prev
? {
...prev,
prestige: result.prestige,
skills: result.skills,
}
: prev
);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
const statusLabel = !data?.canAfford
? `Need ${formatCredits(shortfall)} cr more`
: data?.lockedItems > 0
? `Withdraw ${data.lockedItems} locked item${data.lockedItems === 1 ? '' : 's'} first`
: `Ready · ~${data.previewPr} Pr`;
return (
<section className="section">
<div className="section-head">
<div>
<section className="section prestige-page">
<div className="section-head prestige-head">
<div className="prestige-head-copy">
<h1>Prestige</h1>
<p className="muted">
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
<p className="prestige-lead">
Reset your progress for <strong className="gold">Pr</strong>, then spend Pr on permanent
skills.
</p>
</div>
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
<div className="prestige-pills">
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
</div>
)}
<div className="prestige-pr-pill">
<strong>{prBalance}</strong> Pr
</div>
)}
</div>
</div>
<ol className="prestige-steps">
<li>
<span className="prestige-step-num">1</span>
<div>
<strong>Pay the cost</strong>
<p className="muted">{formatCredits(cost)} cr from your balance</p>
</div>
</li>
<li>
<span className="prestige-step-num">2</span>
<div>
<strong>Earn Pr</strong>
<p className="muted">
+{p?.prBaseReward ?? 1000} base, more if you are over the cost
</p>
</div>
</li>
<li>
<span className="prestige-step-num">3</span>
<div>
<strong>Upgrade skills</strong>
<p className="muted">Permanent bonuses they survive every reset</p>
</div>
</li>
</ol>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{p && (
<div className="prestige-totals">
<div className="prestige-totals prestige-totals-wide">
<div>
<span className="muted">Chance</span>
<strong>+{p.chanceBonus}%</strong>
@@ -107,19 +173,45 @@ export default function PrestigePage() {
<span className="muted">Key opens</span>
<strong>{p.keyOpenReduction}</strong>
</div>
<div>
<span className="muted">Vault</span>
<strong>{formatCredits(p.startBonus || 0)} cr</strong>
</div>
<div>
<span className="muted">Ad reward</span>
<strong>+{p.adBonus || 0}%</strong>
</div>
<div>
<span className="muted">Ad cooldown</span>
<strong>{p.adCooldownReduction || 0}s</strong>
</div>
<div>
<span className="muted">Polish</span>
<strong>+{p.wearBonus || 0}</strong>
</div>
<div>
<span className="muted">Pr echo</span>
<strong>+{p.prBonus || 0}%</strong>
</div>
<div>
<span className="muted">Haggle</span>
<strong>{p.shopDiscount || 0} osu</strong>
</div>
<div>
<span className="muted">Purse</span>
<strong>+{p.startOsu || 0} osu</strong>
</div>
<div>
<span className="muted">Magnet</span>
<strong>+{p.dropValueBonus || 0}%</strong>
</div>
<div>
<span className="muted">Bulk</span>
<strong>+{p.openBonus || 0}</strong>
</div>
</div>
)}
<div className="prestige-cost-row">
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
{!data?.canAfford && (
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
)}
{data?.lockedItems > 0 && (
<div className="error">
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
@@ -127,68 +219,126 @@ export default function PrestigePage() {
</div>
)}
<div className="prestige-choices">
{(data?.choices || []).map((c) => (
<button
key={c.id}
type="button"
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
onClick={() => setSelected(c.id)}
disabled={busy || !data?.canPrestige}
>
<h2>{c.title}</h2>
<p className="muted">{c.description}</p>
<div className="prestige-choice-meta">
<span>
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
{c.id === 'key' ? ' opens reduced' : ''}
</span>
<span className="prestige-delta">
+{c.current + c.delta}
{c.id === 'key' ? '' : '%'}
</span>
</div>
</button>
))}
</div>
<div className="prestige-action admin-block">
<div className="prestige-action-head">
<h2>Reset now</h2>
<span className={`prestige-status${data?.canPrestige ? ' ready' : ''}`}>
{statusLabel}
</span>
</div>
<div className="prestige-confirm admin-block">
<p>
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
<div className="prestige-action-stats">
<div>
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
</div>
<div>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
<div>
<span className="muted">Est. Pr reward</span>
<strong className="prestige-pr-est">
{data?.canAfford ? `~${data.previewPr} Pr` : '—'}
</strong>
</div>
</div>
<div className="prestige-action-split">
<div>
<h3>You keep</h3>
<ul>
<li>All Pr already earned</li>
<li>Skill-tree bonuses</li>
<li>Account &amp; prestige count</li>
</ul>
</div>
<div>
<h3>You lose</h3>
<ul>
<li>Balance (minus Vault / Purse)</li>
<li>Inventory</li>
<li>Case keys &amp; catalog progress</li>
</ul>
</div>
</div>
<p className="muted prestige-pr-hint">
Reward: +{p?.prBaseReward ?? 1000} Pr base. Every{' '}
{formatCredits(p?.excessChunkCents ?? 0)} cr above the cost adds ~{p?.prPerExcessChunk ?? 1000}{' '}
Pr.
</p>
<label>
Confirmation
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE"
disabled={busy || !selected}
autoComplete="off"
/>
</label>
<button
type="button"
className="btn"
disabled={
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
}
className="btn prestige-action-btn"
disabled={!canClick}
onClick={onPrestige}
title={
!data?.canAfford
? `Need ${formatCredits(cost)} cr`
: data?.lockedItems > 0
? 'Withdraw locked items first'
: 'Prestige now'
}
>
{busy ? 'Prestiging…' : 'Prestige now'}
</button>
</div>
<div className="prestige-tree-section">
<div className="section-head">
<div>
<h2>Skill tree</h2>
<p className="muted">Spend Pr on permanent branches. Costs rise with each purchase.</p>
</div>
</div>
<div className="prestige-tree">
<div className="prestige-tree-root" aria-hidden>
<span>Core</span>
<strong>{prBalance} Pr</strong>
</div>
<div className="prestige-tree-branches">
{(data?.skills || []).map((skill) => (
<div key={skill.id} className="prestige-tree-branch">
<div className="prestige-tree-connector" aria-hidden />
<article className="prestige-skill-node">
<h3>{skill.title}</h3>
<p className="muted">{skill.description}</p>
<div className="prestige-skill-meta">
<span>
Lv {skill.level} · now{' '}
<strong>{formatSkillValue(skill, skill.current)}</strong>
</span>
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
</div>
<button
type="button"
className="btn secondary"
disabled={busy || !skill.canAfford}
onClick={() => onUpgrade(skill.id)}
>
Buy · {skill.cost} Pr
</button>
</article>
</div>
))}
</div>
</div>
</div>
{data?.history?.length > 0 && (
<div className="prestige-history">
<h2>History</h2>
<ul className="stat-list">
{data.history.map((h) => (
<li key={h.id}>
{CHOICE_LABELS[h.choice] || h.choice}{' '}
<span className="muted">
· {new Date(h.createdAt).toLocaleString()}
</span>
Prestige reset
{h.prGranted > 0 ? ` · +${h.prGranted} Pr` : ''}{' '}
<span className="muted">· {new Date(h.createdAt).toLocaleString()}</span>
</li>
))}
</ul>