Skip to content

Commit 64973a1

Browse files
Evan Phyillaierclaude
andcommitted
v1.1.1: UX fixes, non-destructive overheat, and an admin panel
- Migrate now returns you to the Racks tab instead of leaving you on whatever tab you were on. - Singularity tab label was truncated to "Singular." - restored to full. - Long usernames (e.g. "NeverEndingCode") were getting cut off in the header - widened the truncation limit. - Collect All now shows whenever any owned rack isn't automated, not only when there's currently something ready to collect. - Overclock overheating no longer destroys any owned nodes - hitting 100% heat now only triggers the 10s lane cooldown. Updated the meltdown modal and warning copy to match. - The post-minigame "Resolved" modal no longer closes on a backdrop click, only via its own button - it was too easy to dismiss by accident. - Added a STABILIZE button back to Overclock Balance, sharing the same scoring logic as clicking the bar directly. - New admin-only section in Profile > Settings (visible and API-gated for a single hardcoded user id) listing every user and their save stats - server/routes/api.js's /api/admin/users checks this on every request rather than trusting anything from the client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 66280a9 commit 64973a1

17 files changed

Lines changed: 152 additions & 24 deletions

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "rackstack-client",
33
"private": true,
4-
"version": "1.1.0",
4+
"version": "1.1.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

client/src/RackStack.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ export default function RackStack({ user }) {
174174
const netHeat = heatGain - eff.autoVentPerSec;
175175
newHeat = Math.min(100, Math.max(0, prev.heat + netHeat * dt));
176176
if (newHeat >= 100) {
177+
// Overheating never destroys nodes - it just forces the cooldown.
177178
meltdown = true;
178-
overclock = prev.overclock.map((o) => ({ ...o, owned: Math.floor(o.owned * 0.5) }));
179179
newHeat = 0;
180180
heatCooldownUntil = now + HEAT_COOLDOWN_MS;
181181
}
@@ -354,6 +354,7 @@ export default function RackStack({ user }) {
354354
setRun({ ...initialRun(), credits: startCredits });
355355
setMeta((prev) => ({ ...prev, legacyCores: prev.legacyCores + gain + echoBonus, stats: { ...prev.stats, migrates: prev.stats.migrates + 1 } }));
356356
setModal(null);
357+
setActiveTab('racks');
357358
}
358359
function doSingularity() {
359360
const shardsGained = Math.floor(Math.sqrt(meta.legacyCores));
@@ -556,6 +557,7 @@ export default function RackStack({ user }) {
556557
const overclockOutput = heatOnCooldown ? 0 : run.overclock.reduce((sum, o, i) => sum + tierRate(o.owned, OVERCLOCK_DEFS[i].baseProd, overclockMult, thresholds), 0);
557558
const totalOutputPerSec = racksOutput + gridOutput + overclockOutput;
558559
const anyReady = run.tiers.some((ts) => !ts.manager && ts.ready > 0.01);
560+
const anyManualOwned = run.tiers.some((ts) => ts.owned > 0 && !ts.manager);
559561
const gain = migrateGain(run.lifetimeRun, eff.legacyGainMult);
560562
const singularityGain = Math.floor(Math.sqrt(meta.legacyCores));
561563

@@ -617,7 +619,7 @@ export default function RackStack({ user }) {
617619
<div className="max-w-2xl mx-auto px-4 pt-3">
618620
<HeaderBar user={user} level={meta.level} onOpenProfile={() => setProfileOpen(true)} />
619621
<StatsRow run={run} meta={meta} totalOutputPerSec={totalOutputPerSec} xpNeeded={xpNeeded} boost={boost} boostMultNow={boostMultNow} />
620-
<MigrateBar gain={gain} anyReady={anyReady} onMigrate={() => setModal({ type: 'migrate' })} onCollectAll={collectAll} />
622+
<MigrateBar gain={gain} showCollectAll={anyManualOwned} collectDisabled={!anyReady} onMigrate={() => setModal({ type: 'migrate' })} onCollectAll={collectAll} />
621623
<TabBar tabs={TABS} activeTab={activeTab} setActiveTab={setActiveTab} gridUnlocked={gridUnlocked} overclockUnlocked={overclockUnlocked} singularityUnlocked={singularityUnlocked} />
622624
</div>
623625
</div>
@@ -673,6 +675,7 @@ export default function RackStack({ user }) {
673675

674676
{profileOpen && (
675677
<ProfileView
678+
user={user}
676679
meta={meta}
677680
memberSince={user && user.memberSince}
678681
onClose={() => setProfileOpen(false)}

client/src/game/components/HeaderBar.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default function HeaderBar({ user, level, onOpenProfile }) {
1616
) : (
1717
<UserCircle size={22} color={textDim} />
1818
)}
19-
<span className="text-xs font-mono truncate max-w-[90px]" style={{ color: textDim }}>{user.username}</span>
19+
<span className="text-xs font-mono truncate max-w-[160px]" style={{ color: textDim }}>{user.username}</span>
2020
</button>
2121
)}
2222
<div className="rounded-lg px-2 py-1 text-xs font-mono" style={{ background: inset, border: `1px solid ${cardBorder}`, color: violet }}>Lv {level}</div>

client/src/game/components/MigrateBar.jsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RefreshCw } from 'lucide-react';
22
import { amber, cardBg, inset, cardBorder, textDim, textMain } from '../theme.js';
33

4-
export default function MigrateBar({ gain, anyReady, onMigrate, onCollectAll }) {
4+
export default function MigrateBar({ gain, showCollectAll, collectDisabled, onMigrate, onCollectAll }) {
55
return (
66
<div className="mt-3 flex gap-2">
77
<button
@@ -12,8 +12,19 @@ export default function MigrateBar({ gain, anyReady, onMigrate, onCollectAll })
1212
>
1313
<RefreshCw size={16} /> Migrate{gain > 0 ? ` (+${gain} cores)` : ''}
1414
</button>
15-
{anyReady && (
16-
<button onClick={onCollectAll} className="rounded-lg px-4 py-2 text-sm font-semibold" style={{ background: inset, border: `1px solid ${cardBorder}`, color: textMain }}>
15+
{showCollectAll && (
16+
<button
17+
onClick={onCollectAll}
18+
disabled={collectDisabled}
19+
className="rounded-lg px-4 py-2 text-sm font-semibold"
20+
style={{
21+
background: collectDisabled ? cardBg : inset,
22+
border: `1px solid ${cardBorder}`,
23+
color: collectDisabled ? textDim : textMain,
24+
opacity: collectDisabled ? 0.55 : 1,
25+
cursor: collectDisabled ? 'not-allowed' : 'pointer',
26+
}}
27+
>
1728
Collect All
1829
</button>
1930
)}

client/src/game/components/OverclockPanel.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
1717
</div>
1818
{run.heat > 80 && !onCooldown && (
1919
<div className="text-xs mt-1 flex items-center gap-1" style={{ color: danger }}>
20-
<AlertTriangle size={12} /> Meltdown risk &mdash; vent now, or you'll lose half your Overclock nodes
20+
<AlertTriangle size={12} /> Overheating risk &mdash; vent now to avoid a mandatory cooldown
2121
</div>
2222
)}
2323
{onCooldown && (
@@ -30,7 +30,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
3030
</button>
3131
</div>
3232
<div className="rounded-lg p-3 text-xs" style={{ background: cardBg, border: `1px solid ${cardBorder}`, color: textDim }}>
33-
Overclock nodes run on their own like the Grid, but generate heat. Let it hit 100% and half your nodes melt down, freezing the lane for 10s. Keep venting.
33+
Overclock nodes run on their own like the Grid, but generate heat. Let it hit 100% and the lane freezes for 10s while it cools down - no nodes are ever lost. Keep venting to avoid the lockout.
3434
</div>
3535
{OVERCLOCK_DEFS.map((def, i) => {
3636
const o = run.overclock[i];

client/src/game/components/minigames/BalanceOverlay.jsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useRef, useEffect } from 'react';
22
import { X } from 'lucide-react';
3-
import { textDim, danger, teal, inset, cardBorder } from '../../theme.js';
3+
import { textDim, textMain, danger, teal, inset, cardBorder } from '../../theme.js';
44
import { BALANCE_SAFE_ZONE_MIN, BALANCE_SAFE_ZONE_MAX, BALANCE_BASE_SPEED, BALANCE_SPEED_VARIANCE } from '../../constants.js';
55

66
// Smooth, rAF-driven indicator. Position lives in a ref and is painted
@@ -40,8 +40,13 @@ export default function BalanceOverlay({ minigame, onBarHit, onMiss, onCancel })
4040
return () => cancelAnimationFrame(rafId);
4141
}, []);
4242

43-
function handleBarClick(e) {
44-
e.stopPropagation();
43+
// Shared by both the bar's own click and the STABILIZE button below it -
44+
// both are "attempt to score now" actions, so they get identical
45+
// in-zone/out-of-zone behavior (silent no-op when out of zone, same as
46+
// clicking the bar directly - only clicks *outside* the bar/button count
47+
// as a miss).
48+
function attemptScore(e) {
49+
if (e) e.stopPropagation();
4550
const inZone = posRef.current >= BALANCE_SAFE_ZONE_MIN && posRef.current <= BALANCE_SAFE_ZONE_MAX;
4651
if (inZone) onBarHit();
4752
}
@@ -58,11 +63,14 @@ export default function BalanceOverlay({ minigame, onBarHit, onMiss, onCancel })
5863
<span>{minigame.timeLeft}s left</span>
5964
<span style={{ color: danger }}>{minigame.score} stabilized</span>
6065
</div>
61-
<div className="relative h-8 rounded-full mb-8 cursor-pointer" style={{ background: inset, border: `1px solid ${cardBorder}` }} onClick={handleBarClick}>
66+
<div className="relative h-8 rounded-full mb-8 cursor-pointer" style={{ background: inset, border: `1px solid ${cardBorder}` }} onClick={attemptScore}>
6267
<div className="absolute top-0 bottom-0" style={{ left: `${BALANCE_SAFE_ZONE_MIN}%`, width: `${BALANCE_SAFE_ZONE_MAX - BALANCE_SAFE_ZONE_MIN}%`, background: 'rgba(79,195,176,0.25)', borderLeft: `1px solid ${teal}`, borderRight: `1px solid ${teal}` }} />
6368
<div ref={needleRef} className="absolute top-0 bottom-0 w-1.5 rounded" style={{ left: 'calc(0% - 3px)', background: danger }} />
6469
</div>
65-
<div className="text-xs" style={{ color: textDim }}>Click the bar while the marker is in the safe zone &mdash; clicking elsewhere costs points</div>
70+
<div className="text-xs mb-4" style={{ color: textDim }}>Click the bar or press STABILIZE while the marker is in the safe zone &mdash; clicking elsewhere costs points</div>
71+
<button onClick={attemptScore} className="w-full rounded-2xl py-4 text-base font-bold" style={{ background: danger, color: textMain }}>
72+
STABILIZE
73+
</button>
6674
</div>
6775
</div>
6876
);

client/src/game/components/modals/MessageModal.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ export default function MessageModal({ modal, onClose }) {
4141
case 'meltdown':
4242
return (
4343
<>
44-
<h2 className="text-lg font-bold mb-2 flex items-center gap-2" style={{ color: danger }}><AlertTriangle size={18} /> Thermal Meltdown!</h2>
45-
<p className="text-sm mb-4" style={{ color: textDim }}>Your Overclock Bay overheated and half your nodes were lost. The lane is frozen for a short cooldown. Keep an eye on the heat gauge and vent regularly, or invest in Thermal Regulators / Auto-Vent upgrades.</p>
44+
<h2 className="text-lg font-bold mb-2 flex items-center gap-2" style={{ color: danger }}><AlertTriangle size={18} /> Overheated!</h2>
45+
<p className="text-sm mb-4" style={{ color: textDim }}>Your Overclock Bay hit 100% heat and the lane is frozen for a short cooldown - no nodes were lost. Keep an eye on the heat gauge and vent regularly, or invest in Thermal Regulators / Auto-Vent upgrades to avoid the lockout.</p>
4646
<button onClick={onClose} className="w-full rounded-lg py-2 text-sm font-semibold" style={{ background: danger, color: textMain }}>Understood</button>
4747
</>
4848
);

client/src/game/components/modals/ModalRoot.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import SingularityConfirmModal from './SingularityConfirmModal.jsx';
55
import ResetConfirmModal from './ResetConfirmModal.jsx';
66
import ResetTypeConfirmModal from './ResetTypeConfirmModal.jsx';
77

8-
const NON_DISMISSIBLE = ['migrate', 'reset', 'resetConfirmType', 'singularity'];
8+
const NON_DISMISSIBLE = ['migrate', 'reset', 'resetConfirmType', 'singularity', 'minigameResult'];
99
const MESSAGE_TYPES = ['welcome', 'eventClaim', 'minigameResult', 'goalClaim', 'levelUp', 'singularityDone', 'meltdown'];
1010

1111
export default function ModalRoot({ modal, setModal, meta, gain, singularityGain, onMigrate, onSingularity, onHardReset }) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useState, useEffect } from 'react';
2+
import { ShieldAlert } from 'lucide-react';
3+
import { cardBorder, textMain, textDim, violet, inset } from '../../theme.js';
4+
5+
export default function AdminPanel() {
6+
const [users, setUsers] = useState(null);
7+
const [error, setError] = useState(null);
8+
9+
useEffect(() => {
10+
let cancelled = false;
11+
fetch('/api/admin/users', { credentials: 'include' })
12+
.then((r) => { if (!r.ok) throw new Error(`request failed (${r.status})`); return r.json(); })
13+
.then((data) => { if (!cancelled) setUsers(data.users); })
14+
.catch(() => { if (!cancelled) setError('Failed to load users.'); });
15+
return () => { cancelled = true; };
16+
}, []);
17+
18+
return (
19+
<div className="rounded-lg p-3" style={{ border: `1px solid ${violet}`, background: 'rgba(156,140,242,0.08)' }}>
20+
<div className="flex items-center gap-1.5 text-xs font-semibold mb-2" style={{ color: violet }}>
21+
<ShieldAlert size={13} /> ADMIN &mdash; ALL USERS
22+
</div>
23+
{error && <div className="text-xs" style={{ color: textDim }}>{error}</div>}
24+
{!users && !error && <div className="text-xs" style={{ color: textDim }}>Loading&hellip;</div>}
25+
{users && users.length === 0 && <div className="text-xs" style={{ color: textDim }}>No users yet.</div>}
26+
{users && users.length > 0 && (
27+
<div className="flex flex-col gap-2 max-h-72 overflow-y-auto">
28+
{users.map((u) => (
29+
<div key={u.id} className="rounded-md p-2 text-xs" style={{ background: inset, border: `1px solid ${cardBorder}` }}>
30+
<div className="flex items-center justify-between font-mono gap-2" style={{ color: textMain }}>
31+
<span className="truncate">{u.username || u.id}</span>
32+
<span style={{ color: textDim }}>{u.provider}</span>
33+
</div>
34+
<div className="mt-0.5" style={{ color: textDim }}>
35+
{u.level != null
36+
? `Lv ${u.level} · ${u.wafers ?? 0} wafers · ${u.legacyCores ?? 0} cores · ${u.singularityShards ?? 0} shards`
37+
: 'No save yet'}
38+
</div>
39+
{u.stats && (
40+
<div style={{ color: textDim }}>
41+
Migrates {u.stats.migrates ?? 0} &middot; Singularities {u.stats.singularities ?? 0} &middot; Minigames won {u.stats.minigamesWon ?? 0}
42+
</div>
43+
)}
44+
</div>
45+
))}
46+
</div>
47+
)}
48+
</div>
49+
);
50+
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { LogOut } from 'lucide-react';
22
import { textMain, inset, cardBorder } from '../../theme.js';
3+
import { ADMIN_USER_ID } from '../../constants.js';
34
import DangerZone from './DangerZone.jsx';
5+
import AdminPanel from './AdminPanel.jsx';
46

5-
export default function ProfileSettings({ onLogout, onOpenReset }) {
7+
export default function ProfileSettings({ user, onLogout, onOpenReset }) {
8+
const isAdmin = !!user && user.id === ADMIN_USER_ID;
69
return (
710
<div className="flex flex-col gap-4">
811
<button onClick={onLogout} className="w-full rounded-lg py-2 text-sm font-semibold flex items-center justify-center gap-2" style={{ background: inset, border: `1px solid ${cardBorder}`, color: textMain }}>
912
<LogOut size={16} /> Log out
1013
</button>
1114
<DangerZone onOpenReset={onOpenReset} />
15+
{isAdmin && <AdminPanel />}
1216
</div>
1317
);
1418
}

0 commit comments

Comments
 (0)