From b296b20b5133c3cfdd2f89cbd1ad0cf1ec411460 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 31 Aug 2026 21:26:15 -0400 Subject: [PATCH 1/2] Let the player choose how core counts are written Legacy Cores were the last number still printed in full. By the mid game that is nineteen digits in a row sized for a phone, and the Migrate button carries a second one beside it - the two numbers a player looks at most often were the two least readable in the game. fmtCores(n, format) offers three renderings. 'full' is the historical one and stays the default, so an untouched save reads exactly as it did before the setting existed. 'letters' walks A..Z then AA, AB - bijective base-26, one letter per power of a thousand, so it cannot run out the way the fixed K/M/G ladder in fmt() does at 10^33. 'scientific' is toExponential(2), matching the shape the Compute Balance readout already falls back to. Both compact modes stay literal below a thousand: "50 cores" reads better than "5.00e+1 cores", and there is nothing to abbreviate down there anyway. The preference is client-side (localStorage), not canonical state - nothing in the simulation reads it, so there is no migration and no server round trip. It lives in a module-level useSyncExternalStore rather than component state because the header chip, the Migrate button, the Singularity panel and the Settings picker all render it and cycling from the chip has to move all of them at once; the alternative was threading a prop through RackStack.jsx to five places. Storage access is try/caught - a private-mode browser falls back to the default instead of taking the header down with a SecurityError. The header chip is now a button that cycles the setting in place, since the number itself is what a player is looking at when they want it changed. The same choice is in Profile > Settings for anyone who would rather pick it explicitly, previewing against their own core count. Applied to every core readout, not just the header: the chip, the Migrate payout, the Singularity panel and its confirmation, Profile stats, and the Legacy Cores leaderboard. Co-Authored-By: Claude Opus 5 --- client/src/game/components/MigrateBar.jsx | 5 +- .../src/game/components/SingularityPanel.jsx | 5 +- client/src/game/components/StatsRow.jsx | 18 +++- .../modals/SingularityConfirmModal.jsx | 5 +- .../components/profile/ProfileSettings.jsx | 44 ++++++++- .../game/components/profile/ProfileStats.jsx | 6 +- .../game/components/profile/ProfileView.jsx | 1 + .../components/social/LeaderboardSection.jsx | 10 +- client/src/game/coreFormat.js | 56 +++++++++++ client/src/game/helpers.js | 2 + shared/gameRules.js | 58 ++++++++++++ tests/coreFormat.test.js | 93 +++++++++++++++++++ 12 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 client/src/game/coreFormat.js create mode 100644 tests/coreFormat.test.js diff --git a/client/src/game/components/MigrateBar.jsx b/client/src/game/components/MigrateBar.jsx index 3068807..381ee5e 100644 --- a/client/src/game/components/MigrateBar.jsx +++ b/client/src/game/components/MigrateBar.jsx @@ -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 (
{showCollectAll && ( {fmt(meta.wafers)} wafers diff --git a/client/src/game/components/modals/SingularityConfirmModal.jsx b/client/src/game/components/modals/SingularityConfirmModal.jsx index 38952d7..e7866a7 100644 --- a/client/src/game/components/modals/SingularityConfirmModal.jsx +++ b/client/src/game/components/modals/SingularityConfirmModal.jsx @@ -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 ( <>

Trigger Singularity?

-

Converts {legacyCores} Legacy Cores into +{singularityGain} Singularity Shards. Your run AND Legacy Cores reset to zero.

+

Converts {fmtCores(legacyCores, coreFormat)} Legacy Cores into +{singularityGain} Singularity Shards. Your run AND Legacy Cores reset to zero.

diff --git a/client/src/game/components/profile/ProfileSettings.jsx b/client/src/game/components/profile/ProfileSettings.jsx index dd8d679..1bdb641 100644 --- a/client/src/game/components/profile/ProfileSettings.jsx +++ b/client/src/game/components/profile/ProfileSettings.jsx @@ -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 @@ -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 ( +
+
+ Core number format +
+
+ How Legacy Core counts are written. Tapping the cores chip in the header cycles it too. +
+
+ {CORE_FORMATS.map((f) => ( + + ))} +
+
+ {sample === null + ? CORE_FORMAT_SAMPLES[format] + : fmtCores(sample, format)} cores +
+
+ ); +} + +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 @@ -122,6 +161,7 @@ export default function ProfileSettings({ user, displayName, onUsernameChanged, })}
)} + {canSeeAdminPanel && }
diff --git a/client/src/game/components/profile/ProfileStats.jsx b/client/src/game/components/profile/ProfileStats.jsx index d88fddd..14ab805 100644 --- a/client/src/game/components/profile/ProfileStats.jsx +++ b/client/src/game/components/profile/ProfileStats.jsx @@ -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 }) { @@ -13,6 +14,7 @@ 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; @@ -20,7 +22,7 @@ export default function ProfileStats({ meta, memberSince }) {
- + diff --git a/client/src/game/components/profile/ProfileView.jsx b/client/src/game/components/profile/ProfileView.jsx index e0e815b..410864b 100644 --- a/client/src/game/components/profile/ProfileView.jsx +++ b/client/src/game/components/profile/ProfileView.jsx @@ -66,6 +66,7 @@ export default function ProfileView({ user, meta, memberSince, displayName, onUs onConfigSaved={onConfigSaved} toursCompleted={toursCompleted} onStartTour={onStartTour} + legacyCores={meta.legacyCores} /> )} diff --git a/client/src/game/components/social/LeaderboardSection.jsx b/client/src/game/components/social/LeaderboardSection.jsx index 9a71295..75494ae 100644 --- a/client/src/game/components/social/LeaderboardSection.jsx +++ b/client/src/game/components/social/LeaderboardSection.jsx @@ -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'; @@ -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` }, @@ -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 (
@@ -88,7 +92,7 @@ export default function LeaderboardSection({ boards, userId, optOut, loading, on {row.username || 'Anonymous'}{mine ? ' (you)' : ''} - {activeDef.format(row.value)} + {activeDef.format(row.value, coreFormat)}
); })} diff --git a/client/src/game/coreFormat.js b/client/src/game/coreFormat.js new file mode 100644 index 0000000..7d73189 --- /dev/null +++ b/client/src/game/coreFormat.js @@ -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); +} diff --git a/client/src/game/helpers.js b/client/src/game/helpers.js index 56195a2..7b28a53 100644 --- a/client/src/game/helpers.js +++ b/client/src/game/helpers.js @@ -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'; diff --git a/shared/gameRules.js b/shared/gameRules.js index 000ad9e..cf0d74f 100644 --- a/shared/gameRules.js +++ b/shared/gameRules.js @@ -42,6 +42,64 @@ export function fmt(n) { const decimals = scaled < 10 ? 2 : scaled < 100 ? 1 : 0; return scaled.toFixed(decimals) + suffixes[tier]; } + +// Legacy-core readout formats. Core counts climb well past the top of +// the K/M/G suffix ladder above, and the cores chip printed them raw - nineteen +// unreadable digits by the mid game. These are the three renderings a player +// can pick between; 'full' is the historical one and stays the default, so an +// existing save reads exactly as it did before the setting existed. +export const CORE_FORMATS = ['full', 'letters', 'scientific']; +export const DEFAULT_CORE_FORMAT = 'full'; +export const CORE_FORMAT_LABELS = { + full: 'Full', + letters: 'ABC', + scientific: 'Sci', +}; +// Shown under each choice in Settings so the difference is visible before +// picking. All three are the same number: 4087353084334554000 cores. +export const CORE_FORMAT_SAMPLES = { + full: '4087353084334554000', + letters: '4.09F', + scientific: '4.09e+18', +}; + +export function normalizeCoreFormat(format) { + return CORE_FORMATS.includes(format) ? format : DEFAULT_CORE_FORMAT; +} +export function nextCoreFormat(format) { + const i = CORE_FORMATS.indexOf(normalizeCoreFormat(format)); + return CORE_FORMATS[(i + 1) % CORE_FORMATS.length]; +} + +// Bijective base-26, one letter per power of a thousand: 1 -> A ... 26 -> Z, +// then 27 -> AA, 28 -> AB. Bijective (not plain base-26) because there is no +// zero digit - 'A' is 1, so AA is 27, not 26. +function coreLetterSuffix(tier) { + let out = ''; + let n = tier; + while (n > 0) { + const rem = (n - 1) % 26; + out = String.fromCharCode(65 + rem) + out; + n = Math.floor((n - 1) / 26); + } + return out; +} + +export function fmtCores(n, format = DEFAULT_CORE_FORMAT) { + const mode = normalizeCoreFormat(format); + if (!isFinite(n)) return '∞'; + if (n < 0) return '-' + fmtCores(-n, mode); + if (mode === 'full') return Math.floor(n).toString(); + // Below a thousand there is nothing to abbreviate, and "5.00e+1 cores" reads + // worse than "50 cores" - so both compact modes stay literal down here. + if (n < 1000) return Math.floor(n).toString(); + if (mode === 'scientific') return n.toExponential(2); + const tier = Math.floor(Math.log10(n) / 3); + const scaled = n / Math.pow(1000, tier); + const decimals = scaled < 10 ? 2 : scaled < 100 ? 1 : 0; + return scaled.toFixed(decimals) + coreLetterSuffix(tier); +} + export function xpForLevel(level) { return Math.floor(50 * Math.pow(level + 1, 1.6)); } diff --git a/tests/coreFormat.test.js b/tests/coreFormat.test.js new file mode 100644 index 0000000..04e713d --- /dev/null +++ b/tests/coreFormat.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { + CORE_FORMATS, + CORE_FORMAT_LABELS, + DEFAULT_CORE_FORMAT, + normalizeCoreFormat, + nextCoreFormat, + fmtCores, +} from '../shared/gameRules.js'; + +describe('core number formats', () => { + it('exposes exactly the three offered formats, with a label for each', () => { + expect(CORE_FORMATS).toEqual(['full', 'letters', 'scientific']); + expect(DEFAULT_CORE_FORMAT).toBe('full'); + for (const f of CORE_FORMATS) expect(typeof CORE_FORMAT_LABELS[f]).toBe('string'); + }); + + it('normalizes anything unrecognized back to the default', () => { + expect(normalizeCoreFormat('letters')).toBe('letters'); + expect(normalizeCoreFormat('scientific')).toBe('scientific'); + expect(normalizeCoreFormat('nonsense')).toBe(DEFAULT_CORE_FORMAT); + expect(normalizeCoreFormat(null)).toBe(DEFAULT_CORE_FORMAT); + expect(normalizeCoreFormat(undefined)).toBe(DEFAULT_CORE_FORMAT); + }); + + it('cycles through the formats and wraps around', () => { + expect(nextCoreFormat('full')).toBe('letters'); + expect(nextCoreFormat('letters')).toBe('scientific'); + expect(nextCoreFormat('scientific')).toBe('full'); + expect(nextCoreFormat('nonsense')).toBe('letters'); // treated as the default + }); + + describe('full', () => { + it('keeps the pre-existing plain-integer rendering', () => { + expect(fmtCores(0, 'full')).toBe('0'); + expect(fmtCores(42, 'full')).toBe('42'); + expect(fmtCores(4087353084334554000, 'full')).toBe('4087353084334554000'); + }); + it('is what an unknown or missing format falls back to', () => { + expect(fmtCores(1234, 'nonsense')).toBe('1234'); + expect(fmtCores(1234)).toBe('1234'); + }); + }); + + describe('letters', () => { + it('leaves values under a thousand as plain integers', () => { + expect(fmtCores(0, 'letters')).toBe('0'); + expect(fmtCores(50, 'letters')).toBe('50'); + expect(fmtCores(999, 'letters')).toBe('999'); + }); + it('walks A, B, C... one letter per power of a thousand', () => { + expect(fmtCores(1e3, 'letters')).toBe('1.00A'); + expect(fmtCores(1e6, 'letters')).toBe('1.00B'); + expect(fmtCores(1e9, 'letters')).toBe('1.00C'); + expect(fmtCores(1e12, 'letters')).toBe('1.00D'); + expect(fmtCores(1e15, 'letters')).toBe('1.00E'); + expect(fmtCores(1e18, 'letters')).toBe('1.00F'); + expect(fmtCores(1e21, 'letters')).toBe('1.00G'); + }); + it('keeps three significant figures like fmt() does', () => { + expect(fmtCores(4087353084334554000, 'letters')).toBe('4.09F'); + expect(fmtCores(12_345, 'letters')).toBe('12.3A'); + expect(fmtCores(123_456, 'letters')).toBe('123A'); + }); + it('continues past Z into AA, AB, ... rather than falling apart', () => { + expect(fmtCores(1e78, 'letters')).toBe('1.00Z'); + expect(fmtCores(1e81, 'letters')).toBe('1.00AA'); + expect(fmtCores(1e84, 'letters')).toBe('1.00AB'); + }); + it('handles negatives and infinities', () => { + expect(fmtCores(-1e6, 'letters')).toBe('-1.00B'); + expect(fmtCores(Infinity, 'letters')).toBe('∞'); + expect(fmtCores(NaN, 'letters')).toBe('∞'); + }); + }); + + describe('scientific', () => { + it('leaves values under a thousand as plain integers', () => { + expect(fmtCores(0, 'scientific')).toBe('0'); + expect(fmtCores(50, 'scientific')).toBe('50'); + expect(fmtCores(999, 'scientific')).toBe('999'); + }); + it('uses the same e+NN shape the balance readout already uses', () => { + expect(fmtCores(1e3, 'scientific')).toBe('1.00e+3'); + expect(fmtCores(4087353084334554000, 'scientific')).toBe('4.09e+18'); + expect(fmtCores(124626540980725780, 'scientific')).toBe('1.25e+17'); + }); + it('handles negatives and infinities', () => { + expect(fmtCores(-1e6, 'scientific')).toBe('-1.00e+6'); + expect(fmtCores(Infinity, 'scientific')).toBe('∞'); + }); + }); +}); From 3aa4774aca50dbc12b5e7d6b98315679a9d0057d Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Mon, 31 Aug 2026 21:26:21 -0400 Subject: [PATCH 2/2] v1.11.1: changelog, version bump Patch rather than minor: v1.12.0 is already claimed by the open Economy Rebalance PR (#18), and stealing the number would leave that branch with nowhere to land. package-lock.json's version field had been stale since v1.7.0 - `npm install` resynced it to 1.11.1 as a side effect, and that correction rides along here rather than as its own commit. Verified: SQLite 2261 passing across 147 files, client builds, and the three formats render as expected through StatsRow, MigrateBar and the Settings picker. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++++++++++++++++ Dockerfile | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d611f..5fa0eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index ccbbd1c..af6af9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package-lock.json b/package-lock.json index ea71c4d..2128d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rackstack-server", - "version": "1.7.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rackstack-server", - "version": "1.7.0", + "version": "1.11.1", "dependencies": { "better-sqlite3": "^11.3.0", "cookie-parser": "^1.4.6", diff --git a/package.json b/package.json index 390dc97..9bee95a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rackstack-server", - "version": "1.11.0", + "version": "1.11.1", "private": true, "type": "module", "scripts": {