Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## v1.11.1

- **Core counts you can actually read.** Legacy Cores were the one number in
the game still printed in full — nineteen digits wide by the time Migrate is
paying out quintillions, in a row sized for a phone. Profile → Settings now
offers three renderings: **Full** (what it always was, still the default),
**ABC** (`4.09F` — one letter per power of a thousand, A through Z and then
AA, AB), and **Sci** (`4.09e+18`). Tapping the cores chip in the header
cycles the same setting without opening Settings.

It applies everywhere a core count appears — the header chip, the Migrate
button's payout, the Singularity panel and its confirmation, Profile stats,
and the Legacy Cores leaderboard. The choice is per-device (it lives in the
browser, not in your save) and is display-only: nothing about the simulation
reads it.

## v1.11.0

- **Things can now go wrong.** Every few hours something breaks: ransomware
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
# published image's version label from that tag - so this literal only
# affects locally-built images, not what GHCR publishes.
LABEL org.opencontainers.image.version="1.11.0"
LABEL org.opencontainers.image.version="1.11.1"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
5 changes: 4 additions & 1 deletion client/src/game/components/MigrateBar.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { RefreshCw } from 'lucide-react';
import { amber, cardBg, inset, cardBorder, textDim, textMain } from '../theme.js';
import { fmtCores } from '../helpers.js';
import { useCoreFormat } from '../coreFormat.js';

export default function MigrateBar({ gain, showCollectAll, collectDisabled, onMigrate, onCollectAll }) {
const coreFormat = useCoreFormat();
return (
<div className="mt-3 flex gap-2" data-tour="migrate-bar">
<button
Expand All @@ -10,7 +13,7 @@ export default function MigrateBar({ gain, showCollectAll, collectDisabled, onMi
className="flex-1 rounded-lg py-2 text-sm font-semibold tracking-wide flex items-center justify-center gap-2"
style={{ background: gain > 0 ? amber : cardBg, color: gain > 0 ? '#0E141B' : textDim, cursor: gain > 0 ? 'pointer' : 'not-allowed' }}
>
<RefreshCw size={16} /> Migrate{gain > 0 ? ` (+${gain} cores)` : ''}
<RefreshCw size={16} /> Migrate{gain > 0 ? ` (+${fmtCores(gain, coreFormat)} cores)` : ''}
</button>
{showCollectAll && (
<button
Expand Down
5 changes: 4 additions & 1 deletion client/src/game/components/SingularityPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Sparkles, Gem } from 'lucide-react';
import { cardBg, violet, textMain, textDim } from '../theme.js';
import { SINGULARITY_DEFS } from '../data/upgrades.js';
import { fmtCores } from '../helpers.js';
import { useCoreFormat } from '../coreFormat.js';

export default function SingularityPanel({ meta, config, singularityGain, onOpenSingularityConfirm, onBuyShard }) {
const coreFormat = useCoreFormat();
return (
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="singularity-list">
<div className="rounded-xl p-4" style={{ background: cardBg, border: `1px solid ${violet}` }}>
Expand All @@ -11,7 +14,7 @@ export default function SingularityPanel({ meta, config, singularityGain, onOpen
<div className="font-semibold text-sm" style={{ color: violet }}>Trigger a Singularity</div>
</div>
<div className="text-xs mb-3" style={{ color: textDim }}>
Converts all {meta.legacyCores} Legacy Cores into Singularity Shards, spent below on permanent perks. Wipes your current run AND your Legacy Cores. Wafers, Upgrades, Level, and Goals are untouched.
Converts all {fmtCores(meta.legacyCores, coreFormat)} Legacy Cores into Singularity Shards, spent below on permanent perks. Wipes your current run AND your Legacy Cores. Wafers, Upgrades, Level, and Goals are untouched.
</div>
<div className="font-mono text-lg mb-3" style={{ color: violet }}>+{singularityGain} Shards</div>
<button onClick={onOpenSingularityConfirm} disabled={singularityGain <= 0} className="w-full rounded-lg py-2 text-sm font-semibold" style={{ background: singularityGain > 0 ? violet : cardBg, color: singularityGain > 0 ? '#0E141B' : textDim, cursor: singularityGain > 0 ? 'pointer' : 'not-allowed' }}>
Expand Down
18 changes: 16 additions & 2 deletions client/src/game/components/StatsRow.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { CircuitBoard, Gem, Zap } from 'lucide-react';
import { cardBg, cardBorder, textDim, amber, teal, violet } from '../theme.js';
import { fmt } from '../helpers.js';
import { fmt, fmtCores, CORE_FORMAT_LABELS } from '../helpers.js';
import { useCoreFormat, cycleCoreFormat } from '../coreFormat.js';

export default function StatsRow({ run, meta, totalOutputPerSec, xpNeeded, boost, boostMultNow }) {
// Tapping the chip cycles Full -> ABC -> Sci in place; the same setting
// also lives in Profile > Settings for players who'd rather pick it there.
const coreFormat = useCoreFormat();
return (
<>
<div className="mt-3 grid grid-cols-2 gap-3" data-tour="header-stats">
Expand All @@ -17,7 +21,17 @@ export default function StatsRow({ run, meta, totalOutputPerSec, xpNeeded, boost
</div>

<div className="mt-2 flex items-center gap-3 text-xs font-mono" style={{ color: textDim }}>
<span className="flex items-center gap-1" style={{ color: teal }}><CircuitBoard size={13} /> {meta.legacyCores} cores</span>
<button
type="button"
onClick={cycleCoreFormat}
title={`Core number format: ${CORE_FORMAT_LABELS[coreFormat]} (tap to change)`}
aria-label={`Legacy cores: ${fmtCores(meta.legacyCores, coreFormat)}. Tap to change the number format.`}
data-testid="cores-chip"
className="flex items-center gap-1 font-mono"
style={{ color: teal, cursor: 'pointer' }}
>
<CircuitBoard size={13} /> {fmtCores(meta.legacyCores, coreFormat)} cores
</button>
<span className="flex items-center gap-1" style={{ color: violet }}><Gem size={13} /> {fmt(meta.wafers)} wafers</span>
<span className="flex-1 h-1 rounded" style={{ background: cardBorder }}>
<span className="block h-1 rounded" style={{ background: violet, width: `${Math.min(100, (meta.xp / xpNeeded) * 100)}%` }} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { inset, cardBorder, textMain, textDim, violet } from '../../theme.js';
import { fmtCores } from '../../helpers.js';
import { useCoreFormat } from '../../coreFormat.js';

export default function SingularityConfirmModal({ legacyCores, singularityGain, onCancel, onConfirm }) {
const coreFormat = useCoreFormat();
return (
<>
<h2 className="text-lg font-bold mb-2" style={{ color: violet }}>Trigger Singularity?</h2>
<p className="text-sm mb-4" style={{ color: textDim }}>Converts {legacyCores} Legacy Cores into +{singularityGain} Singularity Shards. Your run AND Legacy Cores reset to zero.</p>
<p className="text-sm mb-4" style={{ color: textDim }}>Converts {fmtCores(legacyCores, coreFormat)} Legacy Cores into +{singularityGain} Singularity Shards. Your run AND Legacy Cores reset to zero.</p>
<div className="flex gap-2">
<button onClick={onCancel} className="flex-1 rounded-lg py-2 text-sm" style={{ background: inset, color: textMain, border: `1px solid ${cardBorder}` }}>Cancel</button>
<button onClick={onConfirm} className="flex-1 rounded-lg py-2 text-sm font-semibold" style={{ background: violet, color: '#0E141B' }}>Trigger</button>
Expand Down
44 changes: 42 additions & 2 deletions client/src/game/components/profile/ProfileSettings.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { LogOut, GraduationCap } from 'lucide-react';
import { LogOut, GraduationCap, CircuitBoard } from 'lucide-react';
import { textMain, textDim, danger, teal, inset, cardBorder, cardBg, amber } from '../../theme.js';
import DangerZone from './DangerZone.jsx';
import AdminPanel from './AdminPanel.jsx';
import { setUsername } from '../../api.js';
import { USERNAME_RE } from '@shared/validation.js';
import { TOURS } from '@shared/tours.js';
import { CORE_FORMATS, CORE_FORMAT_LABELS, CORE_FORMAT_SAMPLES, fmtCores } from '../../helpers.js';
import { useCoreFormat, setCoreFormat } from '../../coreFormat.js';

// Same rule the server enforces (server/routes/api.js, via shared/validation.js).
// Used here purely for instant inline feedback; the server's regex is still
Expand Down Expand Up @@ -74,7 +76,44 @@ function UsernameForm({ displayName, onUsernameChanged }) {
);
}

export default function ProfileSettings({ user, displayName, onUsernameChanged, onLogout, onOpenReset, onConfigSaved, toursCompleted = [], onStartTour }) {
// Core counts outrun the K/M/G suffix ladder the rest of the UI uses, so the
// header chip used to print them raw. This picks the rendering; the header chip
// itself is also tappable and cycles the same setting.
function CoreFormatPicker({ legacyCores }) {
const format = useCoreFormat();
const sample = Number.isFinite(legacyCores) && legacyCores > 0 ? legacyCores : null;
return (
<div className="rounded-xl p-3" style={{ background: cardBg, border: `1px solid ${cardBorder}` }}>
<div className="flex items-center gap-2 text-sm font-semibold mb-1" style={{ color: textMain }}>
<CircuitBoard size={16} /> Core number format
</div>
<div className="text-xs mb-2" style={{ color: textDim }}>
How Legacy Core counts are written. Tapping the cores chip in the header cycles it too.
</div>
<div className="flex gap-1 rounded-lg p-1" style={{ background: '#0E141B' }}>
{CORE_FORMATS.map((f) => (
<button
key={f}
onClick={() => setCoreFormat(f)}
aria-pressed={format === f}
data-testid={`core-format-${f}`}
className="flex-1 rounded-md py-1.5 text-xs font-semibold"
style={{ background: format === f ? teal : 'transparent', color: format === f ? '#0E141B' : textDim }}
>
{CORE_FORMAT_LABELS[f]}
</button>
))}
</div>
<div className="mt-2 text-xs font-mono truncate" style={{ color: textDim }}>
{sample === null
? CORE_FORMAT_SAMPLES[format]
: fmtCores(sample, format)} cores
</div>
</div>
);
}

export default function ProfileSettings({ user, displayName, onUsernameChanged, onLogout, onOpenReset, onConfigSaved, toursCompleted = [], onStartTour, legacyCores }) {
// Admin-panel UI visibility - the real gate is server-side (server/auth.js
// requireRole per route), this only decides whether to show the section at
// all. /api/me now returns the caller's effective roles (owners implicitly
Expand Down Expand Up @@ -122,6 +161,7 @@ export default function ProfileSettings({ user, displayName, onUsernameChanged,
})}
</div>
)}
<CoreFormatPicker legacyCores={legacyCores} />
<DangerZone onOpenReset={onOpenReset} />
{canSeeAdminPanel && <AdminPanel user={user} onConfigSaved={onConfigSaved} />}
</div>
Expand Down
6 changes: 4 additions & 2 deletions client/src/game/components/profile/ProfileStats.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CircuitBoard, Gem, Sparkles, Trophy, RefreshCw, Gamepad2, ListChecks, Calendar } from 'lucide-react';
import { textMain, textDim, teal, violet, amber } from '../../theme.js';
import { xpForLevel } from '../../helpers.js';
import { xpForLevel, fmtCores } from '../../helpers.js';
import { useCoreFormat } from '../../coreFormat.js';
import { GOAL_DEFS } from '../../data/goals.js';

function StatRow({ Icon, color, label, value }) {
Expand All @@ -13,14 +14,15 @@ function StatRow({ Icon, color, label, value }) {
}

export default function ProfileStats({ meta, memberSince }) {
const coreFormat = useCoreFormat();
const xpNeeded = xpForLevel(meta.level);
const completedCount = Object.keys(meta.goalsCompleted).length;
const joined = memberSince ? new Date(memberSince).toLocaleDateString() : null;
return (
<div>
<StatRow Icon={Trophy} color={violet} label="Level" value={`${meta.level} (${meta.xp}/${xpNeeded} xp)`} />
<StatRow Icon={Gem} color={violet} label="Wafers" value={meta.wafers} />
<StatRow Icon={CircuitBoard} color={teal} label="Legacy Cores" value={meta.legacyCores} />
<StatRow Icon={CircuitBoard} color={teal} label="Legacy Cores" value={fmtCores(meta.legacyCores, coreFormat)} />
<StatRow Icon={Sparkles} color={violet} label="Singularity Shards" value={meta.singularityShards} />
<StatRow Icon={RefreshCw} color={amber} label="Migrates" value={meta.stats.migrates} />
<StatRow Icon={Sparkles} color={violet} label="Singularities" value={meta.stats.singularities} />
Expand Down
1 change: 1 addition & 0 deletions client/src/game/components/profile/ProfileView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default function ProfileView({ user, meta, memberSince, displayName, onUs
onConfigSaved={onConfigSaved}
toursCompleted={toursCompleted}
onStartTour={onStartTour}
legacyCores={meta.legacyCores}
/>
)}

Expand Down
10 changes: 7 additions & 3 deletions client/src/game/components/social/LeaderboardSection.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { cardBg, cardBorder, inset, textMain, textDim, amber } from '../../theme.js';
import { fmt } from '../../helpers.js';
import { fmt, fmtCores } from '../../helpers.js';
import { useCoreFormat } from '../../coreFormat.js';
import { achievementDef } from '@shared/achievements.js';
import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js';

Expand All @@ -9,7 +10,9 @@ import { achievementIcon, TIER_COLOR } from '../../data/achievementIcons.js';
const BOARDS = [
{ key: 'allTimeFlops', label: 'FLOPS', format: (v) => `${fmt(v)} all-time` },
{ key: 'level', label: 'Level', format: (v) => `lv ${v}` },
{ key: 'legacyCores', label: 'Legacy Cores (best)', format: (v) => `${fmt(v)} cores` },
// The one board whose unit the player can restyle - `format` takes the
// chosen core notation so this row matches the header chip.
{ key: 'legacyCores', label: 'Legacy Cores (best)', format: (v, coreFormat) => `${fmtCores(v, coreFormat)} cores` },
{ key: 'singularities', label: 'Singularities', format: (v) => `${fmt(v)}x` },
{ key: 'tapes', label: 'Tapes', format: (v) => `${fmt(v)} tapes` },
{ key: 'latestEventRung', label: 'Last event', format: (v) => `${v} rungs` },
Expand Down Expand Up @@ -41,6 +44,7 @@ export default function LeaderboardSection({ boards, userId, optOut, loading, on
const [active, setActive] = useState('allTimeFlops');
const board = (boards && boards[active]) || [];
const activeDef = BOARDS.find((b) => b.key === active);
const coreFormat = useCoreFormat();

return (
<div className="flex flex-col gap-2">
Expand Down Expand Up @@ -88,7 +92,7 @@ export default function LeaderboardSection({ boards, userId, optOut, loading, on
<span className="truncate">{row.username || 'Anonymous'}{mine ? ' (you)' : ''}</span>
<Badges ids={row.badges} />
</span>
<span className="shrink-0">{activeDef.format(row.value)}</span>
<span className="shrink-0">{activeDef.format(row.value, coreFormat)}</span>
</div>
);
})}
Expand Down
56 changes: 56 additions & 0 deletions client/src/game/coreFormat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useSyncExternalStore } from 'react';
import { DEFAULT_CORE_FORMAT, normalizeCoreFormat, nextCoreFormat } from './helpers.js';

// How the player wants Legacy Core counts rendered (see fmtCores in
// shared/gameRules.js). Purely a display preference, so it lives client-side
// in localStorage rather than in canonical server state - nothing about the
// simulation reads it, and there is no migration to run when it changes.
//
// A module-level external store instead of per-component useState: the cores
// chip in the header, the Migrate button, the Singularity panel and the
// Settings picker all render the same preference, and cycling it from the chip
// has to move all of them at once. useSyncExternalStore keeps them in step
// without threading a prop through RackStack.jsx to five places.
const KEY = 'rackstack:coreFormat';

let current = readStored();
const listeners = new Set();

function readStored() {
try {
return normalizeCoreFormat(localStorage.getItem(KEY));
} catch {
// Private-mode / storage-blocked browsers: fall back to the default rather
// than taking the whole header down with a SecurityError.
return DEFAULT_CORE_FORMAT;
}
}

export function getCoreFormat() {
return current;
}

export function setCoreFormat(format) {
const next = normalizeCoreFormat(format);
if (next === current) return;
current = next;
try {
localStorage.setItem(KEY, next);
} catch {
// Preference still applies for this session, it just won't survive a reload.
}
for (const fn of listeners) fn();
}

export function cycleCoreFormat() {
setCoreFormat(nextCoreFormat(current));
}

function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}

export function useCoreFormat() {
return useSyncExternalStore(subscribe, getCoreFormat, getCoreFormat);
}
2 changes: 2 additions & 0 deletions client/src/game/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
export {
costAt, costForN, maxAffordable, milestoneMult, nextMilestone, tierRate,
fmt, xpForLevel, computeEffects, computeMults, migrateGain,
fmtCores, CORE_FORMATS, CORE_FORMAT_LABELS, CORE_FORMAT_SAMPLES,
DEFAULT_CORE_FORMAT, normalizeCoreFormat, nextCoreFormat,
} from '@shared/gameRules.js';
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
"version": "1.11.0",
"version": "1.11.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading