Skip to content

Commit e1cf360

Browse files
Merge pull request #5 from NeverEndingCode/worktree-v1.6-onboarding-qol
v1.6.0: Onboarding & QoL — a reusable tour framework, heat tunables, Racks fixes
2 parents c0b19c6 + 7ecf6cf commit e1cf360

44 files changed

Lines changed: 3817 additions & 48 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Changelog
22

3+
## v1.6.0
4+
5+
Onboarding & quality of life: a guided tour for new and existing players,
6+
plus four fixes to things that were quietly wrong.
7+
8+
- Guided tour: a spotlight walkthrough that dims the screen, highlights the
9+
real control it is describing, and steps through the whole rack - racks,
10+
Grid, Overclock and heat, upgrades, goals, minigames, Cold Storage, Social,
11+
Singularity, Migrate, and a live event if one is running. It is
12+
unlock-aware, so a brand-new account sees 11 steps covering only what it
13+
can actually reach, and a fully-unlocked account sees all 17. Skip is on
14+
every step, and Escape works too.
15+
- It runs once for existing players as well as new ones, then never again.
16+
Replay it any time from Profile -> Settings -> Tutorials.
17+
- Under the hood this is a tour *framework*, not a single tutorial. Completion
18+
is tracked per named tour, so a future release can ship a short tour
19+
covering only its new feature and show it to existing players without
20+
anyone sitting through the full tutorial a second time. A feature tour's
21+
steps are the same array the full tutorial composes, so the copy is only
22+
ever written once.
23+
- The Overheat popup now dismisses itself after 15 seconds instead of waiting
24+
for a click. Tunable from the Balancing tab (`heat.overheatPopupMs`); set
25+
it to 0 to restore the old click-to-dismiss behaviour.
26+
- Venting now sheds a **percentage** of your heat capacity rather than a flat
27+
amount, so it is no longer quietly weakened by anything that raises
28+
capacity - Summer Surge's `heat.capacity: 4000` overlay, or Heat-Sink Tapes.
29+
The Vent button and the Overclock explainer both show the live numbers
30+
instead of hardcoded ones.
31+
**Admin note:** `heat.ventAmount` is replaced by `heat.ventPercent`
32+
(default 25). At stock settings this is exactly balance-neutral - 25% of the
33+
default 2000 capacity is the 500 it replaced - but if you had tuned
34+
`ventAmount` away from 500, that tuning is reset to 25% and will need
35+
re-tuning in the Balancing tab.
36+
- The Racks "Collect" button now stays visible until the tier is automated,
37+
greying out when there is nothing banked, instead of vanishing for a moment
38+
after every collect.
39+
- Fixed a literal `·` showing up next to automated racks in the Racks
40+
tab.
41+
342
## v1.5.0
443

544
Social & Retention: a daily contracts board, global leaderboards,

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ ENV DB_PATH=/app/data/rackstack.db
3838
LABEL org.opencontainers.image.source="https://github.com/NeverEndingCode/rackstack-server"
3939
LABEL org.opencontainers.image.description="RackStack self-hosted server"
4040
LABEL org.opencontainers.image.licenses="MIT"
41-
LABEL org.opencontainers.image.version="1.5.0"
41+
LABEL org.opencontainers.image.version="1.6.0"
4242

4343
VOLUME ["/app/data"]
4444
EXPOSE 3000

client/src/RackStack.jsx

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef, useMemo } from 'react';
1+
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
22

33
import {
44
TICK_MS, ANOMALY_LABELS, EVENT_REFRESH_THROTTLE_MS, CONFIG_POLL_MS,
@@ -8,7 +8,7 @@ import { cardBorder, textDim, textMain, teal, amber, danger, inset } from './gam
88
import { TABS } from './game/data/tabs.js';
99
import {
1010
fetchState, fetchConfig, makeActionQueue, startMinigame, finishMinigame,
11-
fetchEvent, setLeaderboardOptOut, fetchLeaderboard,
11+
fetchEvent, setLeaderboardOptOut, fetchLeaderboard, setTourCompleted,
1212
} from './game/api.js';
1313
import { evaluate } from '@shared/state.js';
1414
import { applyAction, EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js';
@@ -40,6 +40,10 @@ import MatchOverlay from './game/components/minigames/MatchOverlay.jsx';
4040
import BalanceOverlay from './game/components/minigames/BalanceOverlay.jsx';
4141
import ModalRoot from './game/components/modals/ModalRoot.jsx';
4242
import ProfileView from './game/components/profile/ProfileView.jsx';
43+
import TutorialOverlay from './game/components/TutorialOverlay.jsx';
44+
import { CLIENT_TOURS } from './game/data/tours/index.js';
45+
import { selectTour, resolveSteps } from './game/tourSelection.js';
46+
import { TOURS, TOUR_IDS } from '@shared/tours.js';
4347

4448
/*
4549
RACKSTACK - idle infrastructure tycoon
@@ -76,6 +80,22 @@ function isEventTabVisible(eventProgress, pendingClaims, now) {
7680
return Array.isArray(pendingClaims) && pendingClaims.length > 0;
7781
}
7882

83+
// v1.6: the tour's unlock context. Module scope so the auto-start effect
84+
// (which must live above RackStack's early return, per the rules of hooks)
85+
// and the render path below it share ONE definition of "unlocked" and cannot
86+
// drift apart.
87+
function buildTourCtx(state, now) {
88+
if (!state) return null;
89+
return {
90+
gridUnlocked: state.run.tiers[2].owned >= 1,
91+
overclockUnlocked: state.run.tiers[3].owned >= 1,
92+
singularityUnlocked: state.meta.legacyCores >= 50
93+
|| state.meta.stats.singularities > 0 || state.meta.singularityShards > 0,
94+
coldStorageUnlocked: state.run.tiers[4].owned >= 1, // Server Room
95+
eventLive: isEventLive(state.meta.eventProgress, now),
96+
};
97+
}
98+
7999
// Identity of the EFFECTIVE gameplay config. The stored config's `version`
80100
// alone is not enough: activating or ending a live event changes the numbers
81101
// the server evaluates with (its modifiers are overlaid on the baseline)
@@ -100,6 +120,16 @@ export default function RackStack({ user }) {
100120
const [activeTab, setActiveTab] = useState('racks');
101121
const [minigame, setMinigame] = useState(null);
102122
const [profileOpen, setProfileOpen] = useState(false);
123+
// v1.6 guided tours. `toursCompleted` mirrors users.tours_completed, which
124+
// arrives on the `user` prop from App.jsx's /api/me fetch; `activeTour` is
125+
// { id, steps } once one is running.
126+
const [toursCompleted, setToursCompleted] = useState(
127+
() => (Array.isArray(user?.toursCompleted) ? user.toursCompleted : null),
128+
);
129+
const [activeTour, setActiveTour] = useState(null);
130+
// Spec 4.6: after a tour ends, don't auto-start another until the next app
131+
// load, so a player is never carpet-bombed with several tours in a row.
132+
const tourRanThisLoadRef = useRef(false);
103133
// Live Events (v1.4): the currently (or most-recently, through grace -
104134
// see refreshEventData below) active event's identity/ladder, and the
105135
// opt-out-filtered leaderboard for it. Neither is part of canonical
@@ -946,6 +976,43 @@ export default function RackStack({ user }) {
946976
setMinigameSynced(null);
947977
}
948978

979+
// ---- v1.6 guided tours -------------------------------------------------
980+
// These hooks must stay ABOVE the early return below.
981+
useEffect(() => {
982+
if (!state || !toursCompleted || activeTour || tourRanThisLoadRef.current || modal) return;
983+
const ctx = buildTourCtx(state, Date.now());
984+
const autoStartById = Object.fromEntries(TOURS.map((t) => [t.id, t.autoStart]));
985+
const sel = selectTour(CLIENT_TOURS, TOUR_IDS, toursCompleted, ctx, autoStartById);
986+
if (sel) {
987+
tourRanThisLoadRef.current = true;
988+
setActiveTour(sel);
989+
}
990+
}, [state, toursCompleted, activeTour, modal]);
991+
992+
// Finishing and skipping are the same write: a player who skipped made a
993+
// choice, and re-showing a dismissed tour is the worse failure mode.
994+
// Optimistic - the overlay closes immediately and a failed write only costs
995+
// the tour re-offering next load, which beats blocking on the network.
996+
const endTour = useCallback((tourId) => {
997+
setActiveTour(null);
998+
setToursCompleted((cur) => (cur && cur.includes(tourId) ? cur : [...(cur || []), tourId]));
999+
setTourCompleted(tourId, true).then((res) => {
1000+
if (res && Array.isArray(res.toursCompleted)) setToursCompleted(res.toursCompleted);
1001+
}).catch(() => {});
1002+
}, []);
1003+
1004+
// Profile -> Tutorials -> Replay. Bypasses the once-per-load rule: the
1005+
// player explicitly asked for it.
1006+
const startTour = useCallback((tourId) => {
1007+
const tour = CLIENT_TOURS[tourId];
1008+
if (!tour || !stateRef.current) return;
1009+
const steps = resolveSteps(tour, buildTourCtx(stateRef.current, Date.now()));
1010+
if (steps.length === 0) return;
1011+
setActiveTour({ id: tourId, steps });
1012+
setToursCompleted((cur) => (cur || []).filter((id) => id !== tourId));
1013+
setTourCompleted(tourId, false).catch(() => {});
1014+
}, []);
1015+
9491016
if (!loaded || !state || !config) {
9501017
return (
9511018
<div style={{ minHeight: '100vh', background: '#0E141B', color: textDim, display: 'flex', alignItems: 'center', justifyContent: 'center' }} className="font-mono text-sm">
@@ -975,10 +1042,8 @@ export default function RackStack({ user }) {
9751042
const gain = migrateGain(state.run.lifetimeRun, eff.legacyGainMult);
9761043
const singularityGain = Math.floor(Math.sqrt(state.meta.legacyCores || 0));
9771044

978-
const gridUnlocked = state.run.tiers[2].owned >= 1;
979-
const overclockUnlocked = state.run.tiers[3].owned >= 1;
980-
const singularityUnlocked = state.meta.legacyCores >= 50 || state.meta.stats.singularities > 0 || state.meta.singularityShards > 0;
981-
const coldStorageUnlocked = state.run.tiers[4].owned >= 1; // Server Room
1045+
// Single source of truth with the tour's auto-start effect above.
1046+
const { gridUnlocked, overclockUnlocked, singularityUnlocked, coldStorageUnlocked } = buildTourCtx(state, now);
9821047
const anyReady = state.run.tiers.some((ts) => !ts.manager && ts.ready > 0.01);
9831048
const anyManualOwned = state.run.tiers.some((ts) => ts.owned > 0 && !ts.manager);
9841049

@@ -1067,6 +1132,8 @@ export default function RackStack({ user }) {
10671132
heatColor={heatColor}
10681133
onCooldown={heatOnCooldown}
10691134
cooldownSecondsLeft={cooldownSecondsLeft}
1135+
ventPercent={config.data.heat.ventPercent}
1136+
overheatCooldownMs={config.data.heat.overheatCooldownMs}
10701137
/>
10711138
)}
10721139

@@ -1182,6 +1249,8 @@ export default function RackStack({ user }) {
11821249
onLogout={logout}
11831250
onOpenReset={() => setModal({ type: 'reset' })}
11841251
onConfigSaved={handleConfigSaved}
1252+
toursCompleted={toursCompleted || []}
1253+
onStartTour={(tourId) => { setProfileOpen(false); startTour(tourId); }}
11851254
/>
11861255
)}
11871256

@@ -1194,7 +1263,17 @@ export default function RackStack({ user }) {
11941263
onMigrate={doMigrate}
11951264
onSingularity={doSingularity}
11961265
onHardReset={hardReset}
1266+
meltdownAutoDismissMs={config.data.heat.overheatPopupMs}
11971267
/>
1268+
1269+
{activeTour && (
1270+
<TutorialOverlay
1271+
steps={activeTour.steps}
1272+
onStepChange={setActiveTab}
1273+
onFinish={() => endTour(activeTour.id)}
1274+
onSkip={() => endTour(activeTour.id)}
1275+
/>
1276+
)}
11981277
</div>
11991278
);
12001279
}

client/src/game/api.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export function setLeaderboardOptOut(optOut) {
168168
return postJSON('/api/me/leaderboard-opt-out', { optOut }, 'PUT');
169169
}
170170

171+
// PUT /api/me/tours { tourId, completed } -> { ok, toursCompleted }
172+
// | 400 { error: 'invalid_request' } (unregistered tourId, bad types)
173+
// `completed: false` is the replay path. Completing 'onboarding' marks every
174+
// registered tour complete server-side (spec §4.7), so the response's
175+
// toursCompleted is authoritative - callers should adopt it rather than
176+
// predicting the new set locally.
177+
export function setTourCompleted(tourId, completed) {
178+
return postJSON('/api/me/tours', { tourId, completed }, 'PUT');
179+
}
180+
171181
// GET /api/leaderboard -> { generatedAt, boards: { <boardKey>: [row] } }
172182
// where row is { userId, username, avatarUrl, value, badges: [achievementId] }
173183
//

client/src/game/components/ColdStoragePanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function ColdStoragePanel({ meta, config, onClaimBlock, onClaimAl
4141
const jobDone = job && job.accruedOfflineSec >= jobDurSec;
4242

4343
return (
44-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
44+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="coldstorage-track">
4545
<div className="rounded-lg p-3 flex items-center justify-between" style={{ background: cardBg, border: `1px solid ${cardBorder}` }}>
4646
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: textMain }}>
4747
<CassetteTape size={16} color={teal} /> Tapes

client/src/game/components/EventPanel.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export default function EventPanel({
129129

130130
if (!hasCurrent) {
131131
return (
132-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
132+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="event-ladder">
133133
{pending.map((entry) => (
134134
<PendingClaimCard key={entry.event.id} entry={entry} onClaim={onClaimRung} />
135135
))}
@@ -149,7 +149,7 @@ export default function EventPanel({
149149
const graceMsLeft = Math.max(0, (eventProgress.endsAt + EVENT_CLAIM_GRACE_MS) - now);
150150

151151
return (
152-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
152+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="event-ladder">
153153
<div className="rounded-xl p-4" style={{ background: cardBg, border: `1px solid ${accent}` }}>
154154
<div className="flex items-center gap-2">
155155
{icon ? <span className="text-2xl leading-none">{icon}</span> : <Trophy size={22} color={accent} />}

client/src/game/components/GamesPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function GameCard({ Icon, iconColor, title, desc, btnColor, btnTextColor, onPlay
3333
export default function GamesPanel({ onStartRush, onStartDebug, onStartMatch, onStartBalance, cooldowns, minigamesConfig }) {
3434
const { rush, debug, match, balance } = minigamesConfig;
3535
return (
36-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
36+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="games-list">
3737
<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} />
3838
<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} />
3939
<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} />

client/src/game/components/GoalsPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default function GoalsPanel({ ctx, meta, onClaimGoal, onClaimRepeatable }
77
const [showCompleted, setShowCompleted] = useState(false);
88
const completedCount = Object.keys(meta.goalsCompleted).length;
99
return (
10-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-2">
10+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-2" data-tour="goals-list">
1111
<div className="flex items-center justify-between text-xs font-mono mb-1" style={{ color: textDim }}>
1212
<span>{completedCount}/{GOAL_DEFS.length} completed</span>
1313
<button onClick={() => setShowCompleted((s) => !s)} style={{ color: violet }}>{showCompleted ? 'Hide completed' : 'Show completed'}</button>

client/src/game/components/GridPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { GRID_DEFS } from '../data/tiers.js';
44

55
export default function GridPanel({ run, gridMult, thresholds, onBuy }) {
66
return (
7-
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
7+
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3" data-tour="grid-buy">
88
<div className="rounded-lg p-3 text-xs" style={{ background: cardBg, border: `1px solid ${cardBorder}`, color: textDim }}>
99
The Grid runs on its own &mdash; no automation needed. Volunteers contribute FLOPS straight to your total, all the time.
1010
</div>

client/src/game/components/MigrateBar.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { amber, cardBg, inset, cardBorder, textDim, textMain } from '../theme.js
33

44
export default function MigrateBar({ gain, showCollectAll, collectDisabled, onMigrate, onCollectAll }) {
55
return (
6-
<div className="mt-3 flex gap-2">
6+
<div className="mt-3 flex gap-2" data-tour="migrate-bar">
77
<button
88
onClick={onMigrate}
99
disabled={gain <= 0}

0 commit comments

Comments
 (0)