1- import { useState , useEffect , useRef , useMemo } from 'react' ;
1+ import { useState , useEffect , useRef , useMemo , useCallback } from 'react' ;
22
33import {
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
88import { TABS } from './game/data/tabs.js' ;
99import {
1010 fetchState , fetchConfig , makeActionQueue , startMinigame , finishMinigame ,
11- fetchEvent , setLeaderboardOptOut , fetchLeaderboard ,
11+ fetchEvent , setLeaderboardOptOut , fetchLeaderboard , setTourCompleted ,
1212} from './game/api.js' ;
1313import { evaluate } from '@shared/state.js' ;
1414import { applyAction , EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js' ;
@@ -40,6 +40,10 @@ import MatchOverlay from './game/components/minigames/MatchOverlay.jsx';
4040import BalanceOverlay from './game/components/minigames/BalanceOverlay.jsx' ;
4141import ModalRoot from './game/components/modals/ModalRoot.jsx' ;
4242import 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}
0 commit comments