Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eee6071
Design v1.10: Quality of Life
Aug 8, 2026
cd36491
Plan v1.10 Quality of Life
Aug 8, 2026
3c2fefa
Add the v1.10 execution notes and session-handoff record
Aug 8, 2026
d18f85d
Track peak Legacy Cores so a Singularity cannot erase a standing
Aug 8, 2026
65f73a8
Task 1 complete: Track peak Legacy Cores
Aug 8, 2026
ca15591
Fix: Move recordLegacyCorePeak call before evaluate() early-return guard
Aug 8, 2026
e07cdef
Correct the Task 1 fix sha in the handoff notes
Aug 8, 2026
e15fe5f
Leaderboard shows peak Legacy Cores, so prestiging no longer erases you
Aug 8, 2026
52f5675
Task 2 ledger: Leaderboard reads the peak
Aug 8, 2026
e254e77
Add buy mode 'milestone', with the target computed server-side
Aug 8, 2026
17bb40b
Task 3 ledger notes: server-side buy mode 'milestone'
Aug 8, 2026
9a859f8
Record v1.10 session handoff: Tasks 1-3 done, resume at Task 4
Aug 8, 2026
cae4ba8
Add a buy-to-next-milestone button to Racks, Grid and Overclock
Aug 8, 2026
64109d6
Task 4 notes: milestone buttons ledger entry
Aug 8, 2026
6b93d18
Upgrade panels read live config maximums, not the static defs
Aug 8, 2026
a06b453
Record Task 5 completion in v1.10 notes
Aug 8, 2026
6472f73
Derive minigame personal bests from session history
Aug 8, 2026
e389516
Task 6 notes: minigame personal bests ledger entry
Aug 8, 2026
26ebe15
Record v1.10 handoff: Tasks 1-6 done, resume at Task 7
Aug 8, 2026
3ba9aa7
Show minigame personal bests, and call out a new record
Aug 8, 2026
0140da6
Derive achievement unlocks from progress, so the bar cannot disagree
Aug 8, 2026
dbd1815
Show progress toward each locked badge
Aug 8, 2026
6366d62
v1.10.0: smoke coverage, changelog and version
Aug 8, 2026
ff97460
Triage the deferred minors: driver-parity net, and thresholds off the…
Aug 8, 2026
1f7967a
Record v1.10 completion: all 10 tasks done, minors triaged
Aug 8, 2026
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
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,59 @@
# Changelog

## v1.10.0

- **Triggering a Singularity deleted you from the Legacy Cores leaderboard.**
The board read `meta.legacyCores` — the cores you are holding *right now* —
and then dropped every row whose value was zero. A Singularity spends every
core you have. So the moment a player performed the most demanding action in
the game, the board stopped listing them entirely, and the only way to appear
on it was to have never used what it was measuring.

The board now reads a new lifetime stat, `meta.stats.bestLegacyCores`, and is
labelled **Legacy Cores (best)**. The zero-filter is untouched and is now
simply correct rather than special-cased: a player who reset has a non-zero
best, an account that never played does not.

There is no migration and nothing to backfill. The stat is maintained by one
helper called from `evaluate()`, so an existing save seeds itself from its
current cores the first time the server reconciles it — and from
`singularity()` too, immediately before it zeroes the value, because
`POST /api/actions` applies a whole batch with no evaluation in between and a
Migrate-then-Singularity batch would otherwise destroy the peak before
anything observed it.

- **A buy-to-next-milestone button on Racks, Grid and Overclock.** Every 25,
50, 100, 200, 500 and 1000 units doubles that lane's output, and reaching the
next one previously meant arithmetic and repeated Buy 10s. The new button
buys exactly the remainder — no more, no less.

The **server** computes the target, not the client. The `infiniteloop` shard
upgrade discounts those thresholds, so a client working from a stale config
would ask for the wrong number; the button sends `mode: 'milestone'` and the
reducer resolves it against the same discounted thresholds that decide the
multiplier you actually earn. The cost on the label is display only. A jump
you cannot afford is refused whole — there is no partial buy — and a lane
past its final threshold reports the new `no_milestone` rather than
pretending it could not afford one.

- **Minigame personal bests.** Each card in the Games tab shows your best score
for that game, and finishing a run that beats it says so. Derived server-side
from the `minigame_sessions` rows that already existed, so every score you
set before this shipped is already there — again, no migration.

- **Progress bars on locked badges.** A badge you have not earned now shows how
far along you are. The bar and the unlock read the same number: an
achievement now declares `progress` and `target` instead of a hand-written
boolean, and the unlock is derived from them, so the two cannot drift apart.
The two achievements that are genuinely yes/no (Jackpot, Showed Up) stay
boolean and show no bar. No threshold moved.

- **The Upgrades and Singularity panels read live config maximums.** Both took
an upgrade's ceiling from the static definition, so an admin raising a max
level never reached them — a purchasable upgrade could read as maxed out.
They now read `config.upgrades.maxLevels`, as the Cold Storage panel already
did.

## v1.9.1

- **The SuperTokens login button signed you in and then left you logged out.**
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.9.1"
LABEL org.opencontainers.image.version="1.10.0"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
32 changes: 29 additions & 3 deletions client/src/RackStack.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { cardBorder, textDim, textMain, teal, amber, danger, inset } from './gam
import { TABS } from './game/data/tabs.js';
import {
fetchState, fetchConfig, makeActionQueue, startMinigame, finishMinigame,
fetchEvent, setLeaderboardOptOut, fetchLeaderboard, setTourCompleted,
fetchMinigameBests, fetchEvent, setLeaderboardOptOut, fetchLeaderboard,
setTourCompleted,
} from './game/api.js';
import { evaluate } from '@shared/state.js';
import { applyAction, EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js';
Expand Down Expand Up @@ -119,6 +120,11 @@ export default function RackStack({ user }) {
const [rejectToast, setRejectToast] = useState(null);
const [activeTab, setActiveTab] = useState('racks');
const [minigame, setMinigame] = useState(null);
// Best finished score per game, keyed by the server's game string. Not part
// of canonical `state`: it is derived server-side from minigame_sessions
// rows, so it neither reconciles nor belongs in the save. `{}` until the
// fetch lands, and refreshed after every finished round.
const [minigameBests, setMinigameBests] = useState({});
const [profileOpen, setProfileOpen] = useState(false);
// v1.6 guided tours. `toursCompleted` mirrors users.tours_completed, which
// arrives on the `user` prop from App.jsx's /api/me fetch; `activeTour` is
Expand Down Expand Up @@ -229,6 +235,18 @@ export default function RackStack({ user }) {
return () => clearTimeout(t);
}, [rejectToast]);

// Personal bests: fetched once at mount and again after each finished round,
// so a record the player just set is on the card by the time the result
// modal is dismissed. A failure leaves the previous value standing - the
// bests line is decoration, and blanking it on a transient 401 mid-refresh
// would read as a lost score.
const refreshMinigameBests = useCallback(async () => {
const res = await fetchMinigameBests();
if (res && !res.error && res.bests) setMinigameBests(res.bests);
}, []);

useEffect(() => { refreshMinigameBests(); }, [refreshMinigameBests]);

// Stable label for the current anomaly window - chosen once per window
// (keyed on nextAnomalyAt) rather than re-rolled every render, matching
// the old client-rolled-event feel even though timing itself is now canon.
Expand Down Expand Up @@ -855,7 +873,13 @@ export default function RackStack({ user }) {
? `${mg.pairsFound}/${pairCount} pairs matched — +${wafers} wafers`
: `${mg.pairsFound}/${pairCount} pairs matched — no payout, not fully matched`;
} else text = `${mg.score} stabilizations — +${wafers} wafers`;
// newBest is the server's comparison against every prior finished
// score for this game, made before this round was written. Appended to
// the existing result modal rather than raised as a second popup: the
// player is already looking at exactly this number.
if (res.newBest) text += ' — NEW BEST!';
setModal({ type: 'minigameResult', text });
refreshMinigameBests();
} else {
showToast(REJECT_MESSAGES[res && res.error] || 'Session expired');
}
Expand Down Expand Up @@ -1137,10 +1161,10 @@ export default function RackStack({ user }) {
/>
)}

{activeTab === 'upgrades' && <UpgradesPanel meta={state.meta} onBuy={buyUpgrade} />}
{activeTab === 'upgrades' && <UpgradesPanel meta={state.meta} config={config.data} onBuy={buyUpgrade} />}

{activeTab === 'singularity' && (
<SingularityPanel meta={state.meta} singularityGain={singularityGain} onOpenSingularityConfirm={() => setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} />
<SingularityPanel meta={state.meta} config={config.data} singularityGain={singularityGain} onOpenSingularityConfirm={() => setModal({ type: 'singularity' })} onBuyShard={buyShardUpgrade} />
)}

{activeTab === 'goals' && (
Expand All @@ -1155,6 +1179,7 @@ export default function RackStack({ user }) {
onStartBalance={startBalanceGame}
cooldowns={state.server.gameCooldowns}
minigamesConfig={config.data.minigames}
bests={minigameBests}
/>
)}

Expand Down Expand Up @@ -1191,6 +1216,7 @@ export default function RackStack({ user }) {
{activeTab === 'social' && (
<SocialPanel
meta={state.meta}
ctx={ctx}
serverTime={serverNow}
userId={user && user.id}
boards={leaderboards}
Expand Down
11 changes: 10 additions & 1 deletion client/src/game/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,21 @@ export function startMinigame(game) {
return postJSON('/api/minigame/start', { game });
}

// POST /api/minigame/finish { sessionId, metric } -> { state, wafers }
// POST /api/minigame/finish { sessionId, metric } -> { state, wafers, newBest }
// newBest: true iff this run's (clamped) score beat every prior finished
// score for this game, per GET /api/minigame/bests - false on ties.
// | 410 { error: 'gone' } | 404 { error: 'not_found' } | 429 { error: 'cooldown_active' }
export function finishMinigame(sessionId, metric) {
return postJSON('/api/minigame/finish', { sessionId, metric });
}

// GET /api/minigame/bests -> { bests: { <game>: number } }
// Derived server-side from finished sessions, so it is populated with scores
// the player set before this feature existed - no backfill was needed.
export function fetchMinigameBests() {
return request('/api/minigame/bests');
}

// PUT /api/me/username { username } -> { ok, username }
// | 400 { error: 'invalid_username' } | 409 { error: 'taken' }
export function setUsername(name) {
Expand Down
21 changes: 15 additions & 6 deletions client/src/game/components/GamesPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Zap, Bug, Cable, Flame } from 'lucide-react';
import { cardBg, cardBorder, textMain, textDim, amber, teal, violet, danger } from '../theme.js';

function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay, cooldownUntil }) {
function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay, cooldownUntil, best, unit }) {
const [, forceTick] = useState(0);
useEffect(() => {
if (!cooldownUntil || Date.now() >= cooldownUntil) return undefined;
Expand All @@ -18,6 +18,11 @@ function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay
<div className="font-semibold text-sm" style={{ color: textMain }}>{title}</div>
</div>
<div className="text-xs mb-3" style={{ color: textDim }}>{desc}</div>
{typeof best === 'number' && (
<div className="text-xs font-mono mb-2" style={{ color: textDim }}>
best {best} {unit}
</div>
)}
<button
onClick={onPlay}
disabled={onCooldown}
Expand All @@ -30,14 +35,18 @@ function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay
);
}

export default function GamesPanel({ onStartRush, onStartDebug, onStartMatch, onStartBalance, cooldowns, minigamesConfig }) {
// `bests` is keyed by the same game strings the server stores in
// minigame_sessions.game (see MINIGAMES in server/routes/api.js), and is `{}`
// until GET /api/minigame/bests resolves - a card with no entry simply renders
// no best line rather than a zero, so a game never claims a score of 0.
export default function GamesPanel({ onStartRush, onStartDebug, onStartMatch, onStartBalance, cooldowns, minigamesConfig, bests = {} }) {
const { rush, debug, match, balance } = minigamesConfig;
return (
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="games-list">
<GameCard Icon={Zap} iconColor={amber} title="Overclock Rush" desc={`Tap as fast as you can for ${rush.durationSec} seconds.`} btnColor={amber} btnTextColor="#0E141B" onPlay={onStartRush} cooldownUntil={cooldowns.rush} />
<GameCard Icon={Bug} iconColor={teal} title="Debug Sprint" desc={`Squash the highlighted bugs before they hide - up to ${debug.maxLit} can appear at once. ${debug.durationSec} seconds.`} btnColor={teal} btnTextColor="#0E141B" onPlay={onStartDebug} cooldownUntil={cooldowns.debug} />
<GameCard Icon={Cable} iconColor={violet} title="Cable Match" desc={`Find all ${match.pairCount} matching pairs - the round ends the instant you finish. Only a full match pays out. ${match.durationSec} seconds.`} btnColor={violet} btnTextColor="#0E141B" onPlay={onStartMatch} cooldownUntil={cooldowns.match} />
<GameCard Icon={Flame} iconColor={danger} title="Overclock Balance" desc={`Click the bar when the moving marker is in the safe zone. Clicking elsewhere costs you points. ${balance.durationSec} seconds.`} btnColor={danger} btnTextColor={textMain} onPlay={onStartBalance} cooldownUntil={cooldowns.balance} />
<GameCard Icon={Zap} iconColor={amber} title="Overclock Rush" desc={`Tap as fast as you can for ${rush.durationSec} seconds.`} btnColor={amber} btnTextColor="#0E141B" onPlay={onStartRush} cooldownUntil={cooldowns.rush} best={bests.rush} unit="taps" />
<GameCard Icon={Bug} iconColor={teal} title="Debug Sprint" desc={`Squash the highlighted bugs before they hide - up to ${debug.maxLit} can appear at once. ${debug.durationSec} seconds.`} btnColor={teal} btnTextColor="#0E141B" onPlay={onStartDebug} cooldownUntil={cooldowns.debug} best={bests.debug} unit="squashed" />
<GameCard Icon={Cable} iconColor={violet} title="Cable Match" desc={`Find all ${match.pairCount} matching pairs - the round ends the instant you finish. Only a full match pays out. ${match.durationSec} seconds.`} btnColor={violet} btnTextColor="#0E141B" onPlay={onStartMatch} cooldownUntil={cooldowns.match} best={bests.match} unit="pairs" />
<GameCard Icon={Flame} iconColor={danger} title="Overclock Balance" desc={`Click the bar when the moving marker is in the safe zone. Clicking elsewhere costs you points. ${balance.durationSec} seconds.`} btnColor={danger} btnTextColor={textMain} onPlay={onStartBalance} cooldownUntil={cooldowns.balance} best={bests.balance} unit="points" />
</div>
);
}
18 changes: 18 additions & 0 deletions client/src/game/components/GridPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ export default function GridPanel({ run, gridMult, thresholds, onBuy }) {
Max{maxN >= 1 ? ` +${maxN}` : ''}
</button>
</div>
{(() => {
const next = nextMilestone(g.owned, thresholds);
if (next === null) return null; // past the last milestone
const n = next - g.owned;
const costN = costForN(def, g.owned, n);
const affordable = run.credits >= costN;
return (
<button
onClick={() => onBuy(i, 'milestone')}
disabled={!affordable}
title={`Reach ${next} for a 2x output multiplier on this lane`}
className="rounded-lg py-2 text-xs font-mono w-full mt-2"
style={buyBtnStyle(affordable)}
>
{`→ ${next}: ${n} for ${fmt(costN)}`}
</button>
);
})()}
</div>
);
})}
Expand Down
18 changes: 18 additions & 0 deletions client/src/game/components/OverclockPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
Max{maxN >= 1 ? ` +${maxN}` : ''}
</button>
</div>
{(() => {
const next = nextMilestone(o.owned, thresholds);
if (next === null) return null; // past the last milestone
const n = next - o.owned;
const costN = costForN(def, o.owned, n);
const affordable = run.credits >= costN && !onCooldown;
return (
<button
onClick={() => onBuy(i, 'milestone')}
disabled={!affordable}
title={`Reach ${next} for a 2x output multiplier on this lane`}
className="rounded-lg py-2 text-xs font-mono w-full mt-2"
style={buyBtnStyle(affordable)}
>
{`→ ${next}: ${n} for ${fmt(costN)}`}
</button>
);
})()}
</div>
);
})}
Expand Down
18 changes: 18 additions & 0 deletions client/src/game/components/RacksPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e
Max{maxN >= 1 ? ` +${maxN}` : ''}
</button>
</div>
{(() => {
const next = nextMilestone(ts.owned, thresholds);
if (next === null) return null; // past the last milestone
const n = next - ts.owned;
const costN = costForN(def, ts.owned, n);
const affordable = run.credits >= costN;
return (
<button
onClick={() => onBuy(i, 'milestone')}
disabled={!affordable}
title={`Reach ${next} for a 2x output multiplier on this lane`}
className="rounded-lg py-2 text-xs font-mono w-full mt-2"
style={buyBtnStyle(affordable)}
>
{`→ ${next}: ${n} for ${fmt(costN)}`}
</button>
);
})()}
</div>
);
})}
Expand Down
10 changes: 7 additions & 3 deletions client/src/game/components/SingularityPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Sparkles, Gem } from 'lucide-react';
import { cardBg, violet, textMain, textDim } from '../theme.js';
import { SINGULARITY_DEFS } from '../data/upgrades.js';

export default function SingularityPanel({ meta, singularityGain, onOpenSingularityConfirm, onBuyShard }) {
export default function SingularityPanel({ meta, config, singularityGain, onOpenSingularityConfirm, onBuyShard }) {
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 @@ -21,14 +21,18 @@ export default function SingularityPanel({ meta, singularityGain, onOpenSingular
<div className="font-mono text-sm flex items-center gap-1" style={{ color: violet }}><Gem size={14} /> {meta.singularityShards} Shards available</div>
{SINGULARITY_DEFS.map((u) => {
const level = meta.shardUpgrades[u.id] || 0;
const maxed = level >= u.maxLevel;
// Max level is read live from config.upgrades.maxLevels (admin-tunable,
// same source the reducer's buyShardUpgrade() enforces) rather than the
// static u.maxLevel on the def.
const maxLevel = (config.upgrades.maxLevels && config.upgrades.maxLevels[u.id]) ?? u.maxLevel;
const maxed = level >= maxLevel;
const cost = Math.ceil(u.baseCost * Math.pow(u.costMult, level));
const afford = meta.singularityShards >= cost;
return (
<div key={u.id} className="rounded-xl p-3" style={{ background: cardBg, border: `1px solid ${violet}` }}>
<div className="flex items-center justify-between">
<div className="font-semibold text-sm" style={{ color: textMain }}>{u.name}</div>
<div className="font-mono text-xs" style={{ color: violet }}>Lv {level}/{u.maxLevel}</div>
<div className="font-mono text-xs" style={{ color: violet }}>Lv {level}/{maxLevel}</div>
</div>
<div className="text-xs mt-0.5" style={{ color: textDim }}>{u.desc}</div>
<button
Expand Down
4 changes: 2 additions & 2 deletions client/src/game/components/SocialPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const SECTIONS = [
// reconcile), so it's fetched lazily the first time the Board section is
// opened - and refreshed on each subsequent open, throttled by RackStack.jsx.
export default function SocialPanel({
meta, serverTime, userId, boards, leaderboardLoading, optOut,
meta, ctx, serverTime, userId, boards, leaderboardLoading, optOut,
onClaimContract, onToggleOptOut, onRefreshLeaderboard,
}) {
const [section, setSection] = useState('contracts');
Expand Down Expand Up @@ -60,7 +60,7 @@ export default function SocialPanel({
onToggleOptOut={onToggleOptOut}
/>
)}
{section === 'badges' && <AchievementsSection achievements={meta.achievements} />}
{section === 'badges' && <AchievementsSection achievements={meta.achievements} ctx={ctx} />}
</div>
);
}
Loading
Loading