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/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/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('∞'); + }); + }); +});