@@ -17,7 +21,17 @@ export default function StatsRow({ run, meta, totalOutputPerSec, xpNeeded, boost
-
+
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/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": {
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('∞');
+ });
+ });
+});