@tastic/shell 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/assets/sounds/notification.wav +0 -0
- package/assets/sounds/select.wav +0 -0
- package/dist/index.d.mts +487 -0
- package/dist/index.d.ts +487 -0
- package/dist/index.js +1636 -0
- package/dist/index.mjs +1562 -0
- package/dist/notification-CGGFPYK7.wav +0 -0
- package/dist/select-MLBNNA4N.wav +0 -0
- package/package.json +163 -4
- package/src/PackContext.tsx +22 -0
- package/src/RootLayout.tsx +186 -0
- package/src/components/Fonts.tsx +41 -0
- package/src/components/GuideHost.tsx +104 -0
- package/src/components/Providers.tsx +86 -0
- package/src/components/SeatTwoPanel.tsx +132 -0
- package/src/components/SettingsDialog.tsx +42 -0
- package/src/components/Theme.tsx +75 -0
- package/src/components/UpdateDialog.tsx +13 -0
- package/src/components/imageGameIcon.tsx +13 -0
- package/src/gameStats.tsx +90 -0
- package/src/hooks/useActiveRound.ts +16 -0
- package/src/hooks/useAppSettings.ts +19 -0
- package/src/hooks/useFrozenWhileHidden.ts +33 -0
- package/src/hooks/useLastActiveGame.tsx +73 -0
- package/src/hooks/useOpponent.ts +14 -0
- package/src/hooks/useProfiles.tsx +134 -0
- package/src/hooks/useSeatColors.ts +148 -0
- package/src/index.ts +39 -0
- package/src/navigation.ts +11 -0
- package/src/playerSettings.ts +112 -0
- package/src/redux/appSettingsSlice.ts +34 -0
- package/src/redux/createPackStore.ts +78 -0
- package/src/redux/gameGuideSlice.ts +39 -0
- package/src/redux/lastActiveGameSlice.ts +12 -0
- package/src/redux/liveplaySlice.ts +34 -0
- package/src/redux/onProfileRemoved.ts +25 -0
- package/src/redux/opponentSlice.ts +21 -0
- package/src/redux/profileSelectionSlice.ts +17 -0
- package/src/redux/seatColorsSlice.ts +24 -0
- package/src/redux/shellState.ts +59 -0
- package/src/screens/GameRoute.tsx +48 -0
- package/src/screens/HomeScreen.tsx +378 -0
- package/src/screens/LoadoutScreen.tsx +271 -0
- package/src/screens/ProfilesRoute.tsx +14 -0
- package/src/screens/StatsScreen.tsx +133 -0
- package/src/screens/homeLayout.ts +129 -0
- package/src/splashGate.ts +3 -0
- package/src/types.ts +216 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type CreateProfileInput, createProfileRecord, Profile, profilesActions, resolveInitialProfiles, useSharedProfilesSync } from '@tastic/profile'
|
|
2
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'
|
|
3
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
4
|
+
import type { Action, Dispatch } from 'redux'
|
|
5
|
+
|
|
6
|
+
import { profileSelectionActions } from '../redux/profileSelectionSlice'
|
|
7
|
+
import type { ShellState } from '../redux/shellState'
|
|
8
|
+
import type { Seat } from '../types'
|
|
9
|
+
|
|
10
|
+
// Same group id BoxHockey and LightCycles both declare in app.json's ios.entitlements (and any
|
|
11
|
+
// future @tastic game that wants into the same shared roster) - see @tastic/profile's own README
|
|
12
|
+
// for why only the base identity fields travel through this.
|
|
13
|
+
const SHARED_GROUP_ID = 'group.com.infinitetoken.tastic'
|
|
14
|
+
|
|
15
|
+
interface ProfilesContextValue {
|
|
16
|
+
profiles: Profile[]
|
|
17
|
+
lastSelected: Record<Seat, string | null>
|
|
18
|
+
// True once the one-time initial reconciliation below (redux-persist's own already-rehydrated
|
|
19
|
+
// `profiles` against the shared App Group store) has completed - see ProfilesProvider's own doc.
|
|
20
|
+
// RootLayout's GatedApp reads this to hold its children back (see splashGate.ts's
|
|
21
|
+
// 'profiles' gate) until real profile data exists, rather than the empty starting state.
|
|
22
|
+
loaded: boolean
|
|
23
|
+
createProfile: (input: CreateProfileInput) => Profile
|
|
24
|
+
updateProfile: (id: string, patch: Partial<CreateProfileInput>) => void
|
|
25
|
+
// Also clears lastSelected for any seat currently pointing at this id, so /loadout immediately
|
|
26
|
+
// reflects Player (no profile) for that seat rather than a dangling id until next reload.
|
|
27
|
+
deleteProfile: (id: string) => void
|
|
28
|
+
selectProfile: (seat: Seat, profileId: string | null) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ProfilesContext = createContext<ProfilesContextValue | null>(null)
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
children: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Single source of truth for saved player profiles, mounted once at the root (RootLayout): expo-router
|
|
38
|
+
// keeps prior screens mounted, so a per-screen copy could go stale or clobber a concurrent update.
|
|
39
|
+
//
|
|
40
|
+
// `profiles` lives in Redux (the shell's `profiles` key, @tastic/profile's own profilesReducer),
|
|
41
|
+
// persisted locally like every other key, with the shared App Group roster layered on top as a
|
|
42
|
+
// separate, cross-app sync target (see resolveInitialProfiles/useSharedProfilesSync below).
|
|
43
|
+
// `lastSelected` is profileSelectionSlice.ts: which profile seat 1/2 last picked is a per-app thing,
|
|
44
|
+
// never shared cross-app. Only the base identity fields live here; a game that keeps something per
|
|
45
|
+
// profile (Box Hockey's control scheme) keeps it in its own slice.
|
|
46
|
+
//
|
|
47
|
+
// Gating stays external (RootLayout's GatedApp holds the app behind the 'profiles' splash gate
|
|
48
|
+
// until `loaded`); this provider just exposes `loaded` and always renders `children` at once.
|
|
49
|
+
export function ProfilesProvider({ children }: Props) {
|
|
50
|
+
const profiles = useSelector((state: ShellState) => state.profiles)
|
|
51
|
+
const lastSelected = useSelector((state: ShellState) => state.profileSelection)
|
|
52
|
+
const dispatch = useDispatch<Dispatch<Action>>()
|
|
53
|
+
const [loaded, setLoaded] = useState(false)
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let cancelled = false
|
|
57
|
+
// PersistGate (an ancestor - see components/Providers.tsx) already blocks this component from
|
|
58
|
+
// mounting until redux-persist has rehydrated, so `profiles` here is already whatever this
|
|
59
|
+
// device last had locally - resolveInitialProfiles reconciles that against the shared App
|
|
60
|
+
// Group store (if available), seeding the shared store from it on a first sync, or preferring
|
|
61
|
+
// the shared roster once anything has been shared. A one-time reconciliation against the
|
|
62
|
+
// mount-time snapshot, deliberately not a resync whenever `profiles` changes afterward (the
|
|
63
|
+
// empty deps array below is intentional, not a missed dependency - resolveInitialProfiles' own
|
|
64
|
+
// setAll dispatch would otherwise re-trigger this effect).
|
|
65
|
+
resolveInitialProfiles(SHARED_GROUP_ID, profiles).then((resolved) => {
|
|
66
|
+
if (cancelled) return
|
|
67
|
+
dispatch(profilesActions.setAll(resolved))
|
|
68
|
+
// A seat can still point at a profile a sibling app deleted since this device last ran.
|
|
69
|
+
dispatch(profileSelectionActions.pruneToRoster(resolved.map((p) => p.id)))
|
|
70
|
+
setLoaded(true)
|
|
71
|
+
})
|
|
72
|
+
return () => {
|
|
73
|
+
cancelled = true
|
|
74
|
+
}
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
// Mirrors local writes out to the shared store (syncToShared, called from each CRUD function
|
|
79
|
+
// below) and picks up remote writes a sibling @tastic app made while this one was backgrounded
|
|
80
|
+
// (onRemoteChange) - see @tastic/profile's own doc for both.
|
|
81
|
+
const { syncToShared } = useSharedProfilesSync({
|
|
82
|
+
groupId: SHARED_GROUP_ID,
|
|
83
|
+
onRemoteChange: useCallback(
|
|
84
|
+
(remote: Profile[]) => {
|
|
85
|
+
dispatch(profilesActions.setAll(remote))
|
|
86
|
+
// Same job as deleteProfile's clearProfile below, for profiles a sibling app deleted while this one was backgrounded.
|
|
87
|
+
dispatch(profileSelectionActions.pruneToRoster(remote.map((p) => p.id)))
|
|
88
|
+
},
|
|
89
|
+
[dispatch]
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const createProfile = useCallback(
|
|
94
|
+
(input: CreateProfileInput) => {
|
|
95
|
+
const record = createProfileRecord(input)
|
|
96
|
+
dispatch(profilesActions.add(record))
|
|
97
|
+
syncToShared([...profiles, record])
|
|
98
|
+
return record
|
|
99
|
+
},
|
|
100
|
+
[profiles, dispatch, syncToShared]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const updateProfile = useCallback(
|
|
104
|
+
(id: string, patch: Partial<CreateProfileInput>) => {
|
|
105
|
+
dispatch(profilesActions.update({ id, patch }))
|
|
106
|
+
syncToShared(profiles.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p)))
|
|
107
|
+
},
|
|
108
|
+
[profiles, dispatch, syncToShared]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const deleteProfile = useCallback(
|
|
112
|
+
(id: string) => {
|
|
113
|
+
dispatch(profilesActions.remove(id))
|
|
114
|
+
syncToShared(profiles.filter((p) => p.id !== id))
|
|
115
|
+
dispatch(profileSelectionActions.clearProfile(id))
|
|
116
|
+
},
|
|
117
|
+
[profiles, dispatch, syncToShared]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const selectProfile = useCallback(
|
|
121
|
+
(seat: Seat, profileId: string | null) => {
|
|
122
|
+
dispatch(profileSelectionActions.select({ profileId, seat }))
|
|
123
|
+
},
|
|
124
|
+
[dispatch]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return <ProfilesContext.Provider value={{ profiles, lastSelected, loaded, createProfile, updateProfile, deleteProfile, selectProfile }}>{children}</ProfilesContext.Provider>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function useProfiles() {
|
|
131
|
+
const ctx = useContext(ProfilesContext)
|
|
132
|
+
if (!ctx) throw new Error('useProfiles must be used within a ProfilesProvider')
|
|
133
|
+
return ctx
|
|
134
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getThirdColor, useAutoPaperTheme, useThemeSettings } from '@rific/auto-paper'
|
|
2
|
+
import { avoidSeatColorClash, profileSeatColor } from '@tastic/profile'
|
|
3
|
+
import { useFocusEffect } from 'expo-router'
|
|
4
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
5
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
6
|
+
import type { Action, Dispatch } from 'redux'
|
|
7
|
+
|
|
8
|
+
import { usePack } from '../PackContext'
|
|
9
|
+
import { seatColorsActions } from '../redux/seatColorsSlice'
|
|
10
|
+
import type { ShellState } from '../redux/shellState'
|
|
11
|
+
import type { Seat } from '../types'
|
|
12
|
+
import { useProfiles } from './useProfiles'
|
|
13
|
+
|
|
14
|
+
// Both seats' live colors (the theme's primary and secondary) and the handlers that change them,
|
|
15
|
+
// for Home's who's-playing button and the loadout's seat buttons. `p2IsHuman` says whether seat 2
|
|
16
|
+
// is a person (a guest or a profile) or the CPU, which decides where seat 2's color comes from.
|
|
17
|
+
export function useSeatColors(p2IsHuman: boolean) {
|
|
18
|
+
const { colors: themeColors } = useAutoPaperTheme()
|
|
19
|
+
const { set: setThemeColor } = useThemeSettings()
|
|
20
|
+
const p1Color = themeColors.primary
|
|
21
|
+
const p2Color = themeColors.secondary
|
|
22
|
+
const { profiles, lastSelected } = useProfiles()
|
|
23
|
+
// What @tastic/profile's avoidSeatColorClash moves seat 2 to when both seats land on one color: a
|
|
24
|
+
// profile picked for one seat can be saved as the other seat's color, with no swap step behind it.
|
|
25
|
+
// The pack's own seat colors, which never change while it runs.
|
|
26
|
+
const { seatColors } = usePack()
|
|
27
|
+
const seatDefaultColors = [seatColors[1], seatColors[2]] as const
|
|
28
|
+
|
|
29
|
+
// Typed explicitly because @tastic/profile's action creators return a plain `{ payload, type }`
|
|
30
|
+
// interface with no index signature, which react-redux's default Dispatch<UnknownAction> rejects.
|
|
31
|
+
const dispatch = useDispatch<Dispatch<Action>>()
|
|
32
|
+
// A seat's live color comes from one of three independently-remembered places depending on what's
|
|
33
|
+
// in that seat - a selected profile's pick in this pack (or its own saved color without one), the
|
|
34
|
+
// CPU's own remembered color, or a guest's own remembered color - all in the `seatColors` slice
|
|
35
|
+
// (seatColorsSlice.ts). A profile's own saved color is never written back to; only the Profiles
|
|
36
|
+
// screen edits it.
|
|
37
|
+
const guestColors = useSelector((state: ShellState) => state.seatColors.lastGuestColor)
|
|
38
|
+
const cpuColor = useSelector((state: ShellState) => state.seatColors.lastCpuColor)
|
|
39
|
+
// Each profile's own color pick in this pack, by profile id, read through @tastic/profile's
|
|
40
|
+
// profileSeatColor. It follows the profile, not the seat: kept through a switch to another profile
|
|
41
|
+
// and back, a move to the other seat, and a relaunch.
|
|
42
|
+
const profileColors = useSelector((state: ShellState) => state.seatColors.profileColors)
|
|
43
|
+
|
|
44
|
+
const p1Profile = profiles.find((p) => p.id === lastSelected[1]) ?? null
|
|
45
|
+
const p2Profile = p2IsHuman ? (profiles.find((p) => p.id === lastSelected[2]) ?? null) : null
|
|
46
|
+
|
|
47
|
+
// Mirrored for the focus effect below, so it can read each seat's current profile and remembered
|
|
48
|
+
// colors without depending on them (profiles is a new array on every useProfiles() render, and
|
|
49
|
+
// useFocusEffect re-runs whenever its callback's identity changes, even while still focused).
|
|
50
|
+
const p1ProfileRef = useRef(p1Profile)
|
|
51
|
+
const p2ProfileRef = useRef(p2Profile)
|
|
52
|
+
const guestColorsRef = useRef(guestColors)
|
|
53
|
+
const cpuColorRef = useRef(cpuColor)
|
|
54
|
+
const profileColorsRef = useRef(profileColors)
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
p1ProfileRef.current = p1Profile
|
|
57
|
+
}, [p1Profile])
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
p2ProfileRef.current = p2Profile
|
|
60
|
+
}, [p2Profile])
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
guestColorsRef.current = guestColors
|
|
63
|
+
}, [guestColors])
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
cpuColorRef.current = cpuColor
|
|
66
|
+
}, [cpuColor])
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
profileColorsRef.current = profileColors
|
|
69
|
+
}, [profileColors])
|
|
70
|
+
|
|
71
|
+
// Keeps each seat's live theme color matching what it's supposed to be right now: on mount, and
|
|
72
|
+
// when a seat's profile changes (a pick, the swap when a seat picks the other seat's profile, or a
|
|
73
|
+
// deletion from Manage), when seat 2 changes between the CPU and a person, when a profile's pick
|
|
74
|
+
// changes, and when the roster resolves. profiles itself is read, not depended on (a new array on
|
|
75
|
+
// every render); profiles.length stands in as a coarse "profiles finished loading" signal.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const nextP1 = p1Profile ? profileSeatColor(p1Profile, profileColors) : guestColors[1]
|
|
78
|
+
const nextP2 = avoidSeatColorClash(nextP1, p2Profile ? profileSeatColor(p2Profile, profileColors) : p2IsHuman ? guestColors[2] : cpuColor, seatDefaultColors)
|
|
79
|
+
if (nextP1.toLowerCase() === p1Color.toLowerCase() && nextP2.toLowerCase() === p2Color.toLowerCase()) return
|
|
80
|
+
setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } })
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length])
|
|
83
|
+
|
|
84
|
+
// The loadout pushes the play screen and Profiles on top of itself and comes back via
|
|
85
|
+
// router.back()/safeBack() - a pop that reveals this same, still-mounted screen. On every focus
|
|
86
|
+
// regain, re-derive each seat's live color from its authoritative source, so editing a profile's
|
|
87
|
+
// own color on the Profiles screen is picked up the instant you're back (a new color for the
|
|
88
|
+
// profile drops its pick, see profileSeatColor). Keyed only on what is a real reason to reapply;
|
|
89
|
+
// everything else is read through the refs above.
|
|
90
|
+
useFocusEffect(
|
|
91
|
+
useCallback(() => {
|
|
92
|
+
const guest = guestColorsRef.current
|
|
93
|
+
const cpu = cpuColorRef.current
|
|
94
|
+
const p1Prof = p1ProfileRef.current
|
|
95
|
+
const p2Prof = p2ProfileRef.current
|
|
96
|
+
const picks = profileColorsRef.current
|
|
97
|
+
const nextP1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1]
|
|
98
|
+
const nextP2 = avoidSeatColorClash(nextP1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, seatDefaultColors)
|
|
99
|
+
setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } })
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
}, [lastSelected[1], lastSelected[2], p2IsHuman, setThemeColor])
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// Routes a seat's just-changed color to whichever slot owns it right now - kept as that profile's
|
|
105
|
+
// pick in this pack (the pack's caption on the seat button's Color row) when a profile's in the
|
|
106
|
+
// seat. Picking the profile's own color drops the pick.
|
|
107
|
+
const persistSeatColor = useCallback(
|
|
108
|
+
(seat: Seat, hex: string) => {
|
|
109
|
+
const profile = seat === 1 ? p1Profile : p2Profile
|
|
110
|
+
if (profile) {
|
|
111
|
+
dispatch(seatColorsActions.setProfileColor({ profile, color: hex }))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (seat === 2 && !p2IsHuman) {
|
|
115
|
+
dispatch(seatColorsActions.setLastCpuColor(hex))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
dispatch(seatColorsActions.setLastGuestColor({ seat, color: hex }))
|
|
119
|
+
},
|
|
120
|
+
[p1Profile, p2Profile, p2IsHuman, dispatch]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// Picking the other seat's exact current color swaps the two instead of no-op'ing - only reachable
|
|
124
|
+
// where that picker allows it (allowSwapTaken: against the CPU only, where the other color is the
|
|
125
|
+
// CPU's, not a second person's choice). Each side of a swap persists by its own seat's rule above.
|
|
126
|
+
const handleP1ColorChange = useCallback(
|
|
127
|
+
(hex: string) => {
|
|
128
|
+
const swapped = hex.toLowerCase() === p2Color.toLowerCase()
|
|
129
|
+
const nextP2 = swapped ? p1Color : p2Color
|
|
130
|
+
setThemeColor({ color: { primary: hex, secondary: nextP2, tertiary: getThirdColor(hex, nextP2) } })
|
|
131
|
+
persistSeatColor(1, hex)
|
|
132
|
+
if (swapped) persistSeatColor(2, nextP2)
|
|
133
|
+
},
|
|
134
|
+
[setThemeColor, p1Color, p2Color, persistSeatColor]
|
|
135
|
+
)
|
|
136
|
+
const handleP2ColorChange = useCallback(
|
|
137
|
+
(hex: string) => {
|
|
138
|
+
const swapped = hex.toLowerCase() === p1Color.toLowerCase()
|
|
139
|
+
const nextP1 = swapped ? p2Color : p1Color
|
|
140
|
+
setThemeColor({ color: { primary: nextP1, secondary: hex, tertiary: getThirdColor(nextP1, hex) } })
|
|
141
|
+
persistSeatColor(2, hex)
|
|
142
|
+
if (swapped) persistSeatColor(1, nextP1)
|
|
143
|
+
},
|
|
144
|
+
[setThemeColor, p1Color, p2Color, persistSeatColor]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange }
|
|
148
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The pack's root and its registry: an app's src/app/_layout.tsx renders RootLayout with its pack
|
|
2
|
+
// and store, and every screen, game and shared piece reads the pack through usePack().
|
|
3
|
+
export { PackProvider, usePack, usePackGame } from './PackContext'
|
|
4
|
+
export { RootLayout } from './RootLayout'
|
|
5
|
+
export { type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type HeroTitleProps, humanSeatsFor, type LoadoutOptionsProps, type PackDefinition, type PackStatsSectionsProps, type RoundMode, roundModeFor, type Seat, type SeatControlLook, type SeatTwo, seatTwoFor } from './types'
|
|
6
|
+
|
|
7
|
+
// The screens behind an app's routes (src/app/ stays in the app: expo-router only reads routes there).
|
|
8
|
+
export { LoadoutRoute, PlayRoute } from './screens/GameRoute'
|
|
9
|
+
export { HomeScreen } from './screens/HomeScreen'
|
|
10
|
+
// The shape of the box a game's Preview draws in on Home (width / height).
|
|
11
|
+
export { PREVIEW_ASPECT } from './screens/homeLayout'
|
|
12
|
+
export { ProfilesRoute } from './screens/ProfilesRoute'
|
|
13
|
+
export { StatsScreen } from './screens/StatsScreen'
|
|
14
|
+
|
|
15
|
+
// What a game's own screens use.
|
|
16
|
+
export { useGameGuide } from './components/GuideHost'
|
|
17
|
+
export { imageGameIcon } from './components/imageGameIcon'
|
|
18
|
+
export { SettingsDialog, type SettingsDialogProps } from './components/SettingsDialog'
|
|
19
|
+
export { useActiveRound } from './hooks/useActiveRound'
|
|
20
|
+
export { useAppSettings } from './hooks/useAppSettings'
|
|
21
|
+
export { useOpponent } from './hooks/useOpponent'
|
|
22
|
+
export { ProfilesProvider, useProfiles } from './hooks/useProfiles'
|
|
23
|
+
export { useSeatColors } from './hooks/useSeatColors'
|
|
24
|
+
export { safeBack } from './navigation'
|
|
25
|
+
|
|
26
|
+
// The store: the pack's one store, the shell's own keys, and what a game builds its keys from.
|
|
27
|
+
export { type AppSettings, appSettingsActions, default as appSettingsReducer, DEFAULT_APP_SETTINGS } from './redux/appSettingsSlice'
|
|
28
|
+
export { createPackStore, type PackStore, type PackStoreConfig, type PackStoreSource } from './redux/createPackStore'
|
|
29
|
+
export { createGameGuideSlice, type GameGuideState } from './redux/gameGuideSlice'
|
|
30
|
+
export { type ActiveRound, liveplayActions, default as liveplayReducer, type LiveplaySliceState } from './redux/liveplaySlice'
|
|
31
|
+
export { forgetProfileAchievements, onProfileRemoved } from './redux/onProfileRemoved'
|
|
32
|
+
export { DEFAULT_OPPONENT, opponentActions, default as opponentReducer, type OpponentState } from './redux/opponentSlice'
|
|
33
|
+
export { defaultProfileSelectionState, profileSelectionActions, default as profileSelectionReducer, type ProfileSelectionState } from './redux/profileSelectionSlice'
|
|
34
|
+
export { createSeatColorsReducer, defaultSeatColorsState, seatColorsActions, type SeatColorsState } from './redux/seatColorsSlice'
|
|
35
|
+
export { createShellReducers, type ShellState } from './redux/shellState'
|
|
36
|
+
|
|
37
|
+
// A game's per-player settings and its stats and achievements (src/shared/ in the apps until now).
|
|
38
|
+
export { createGameStats, type RecordResult } from './gameStats'
|
|
39
|
+
export { createPlayerSettings, type PlayerControlsState, type PlayerSettingsState, seatPlayerKey } from './playerSettings'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { configureNavigation, safeBack } from '@rific/core'
|
|
2
|
+
import { router } from 'expo-router'
|
|
3
|
+
|
|
4
|
+
// safeBack's own implementation (router.canGoBack() ? router.back() : router.replace(fallbackPath))
|
|
5
|
+
// now lives in @rific/core, shared across the fleet - this just injects expo-router's `router` once
|
|
6
|
+
// at module load (the fallback path defaults to '/', already correct for this app's own root route)
|
|
7
|
+
// and re-exports safeBack unchanged, so every existing call site (`safeBack()`, `onBack={safeBack}`,
|
|
8
|
+
// `onHome={safeBack}`, `onConfirm={safeBack}`) keeps working with zero changes.
|
|
9
|
+
configureNavigation({ router })
|
|
10
|
+
|
|
11
|
+
export { safeBack }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react'
|
|
2
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
3
|
+
import type { UnknownAction } from 'redux'
|
|
4
|
+
|
|
5
|
+
import { onProfileRemoved } from './redux/onProfileRemoved'
|
|
6
|
+
import type { ShellState } from './redux/shellState'
|
|
7
|
+
import type { Seat } from './types'
|
|
8
|
+
|
|
9
|
+
const SEATS: readonly Seat[] = [1, 2]
|
|
10
|
+
|
|
11
|
+
// Who a seat's saved settings belong to: the profile picked for it, or that seat's guest. Seat 1's
|
|
12
|
+
// and seat 2's guests are different people, so each keeps its own.
|
|
13
|
+
export function seatPlayerKey(selection: Record<Seat, string | null>, seat: Seat): string {
|
|
14
|
+
return selection[seat] ?? `guest:${seat}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A game's settings with each seat's controls (Box Hockey's shot style, the desktop keys elsewhere).
|
|
18
|
+
type WithControls<C> = { controlScheme: Record<Seat, C> }
|
|
19
|
+
|
|
20
|
+
// Saved per player: the match settings each player last set up as seat 1 (only what they changed;
|
|
21
|
+
// the rest comes from the game's defaults, so a setting added later starts at its default), and
|
|
22
|
+
// the controls each player last played with, in either seat.
|
|
23
|
+
export type PlayerSettingsState<S> = Record<string, Partial<Omit<S, 'controlScheme'>>>
|
|
24
|
+
export type PlayerControlsState<C> = Record<string, C>
|
|
25
|
+
|
|
26
|
+
interface PlayerSettingsOptions<S extends WithControls<C>, C> {
|
|
27
|
+
// The game's id: its store keys are `${id}Settings` and `${id}Controls`.
|
|
28
|
+
id: string
|
|
29
|
+
defaults: S
|
|
30
|
+
// When the two seats can't share one control (a desktop key cluster), every control in menu order:
|
|
31
|
+
// if both seats would get the same one, seat 2 takes the first one seat 1 isn't using (shown and
|
|
32
|
+
// played, never saved). Leave out where both can use the same (Box Hockey's shot styles).
|
|
33
|
+
distinctControls?: readonly C[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One game's loadout, kept per player (Jay, 2026-10-24): the match settings follow seat 1's player,
|
|
37
|
+
// so each person's own setup loads when they're picked; each seat's controls follow the player in
|
|
38
|
+
// that seat. A seat's color pick is the shell's (seatColors, by profile). Seat 2 itself (the CPU, a
|
|
39
|
+
// person or nobody) is pack-wide, and so is everything in appSettings.
|
|
40
|
+
export function createPlayerSettings<S extends WithControls<C>, C>({ id, defaults, distinctControls }: PlayerSettingsOptions<S, C>) {
|
|
41
|
+
const settingsKey = `${id}Settings`
|
|
42
|
+
const controlsKey = `${id}Controls`
|
|
43
|
+
const PATCH = `${settingsKey}/patch`
|
|
44
|
+
const SET_CONTROL = `${controlsKey}/set`
|
|
45
|
+
|
|
46
|
+
const patch = (player: string, changes: Partial<Omit<S, 'controlScheme'>>) => ({ type: PATCH, payload: { player, changes } })
|
|
47
|
+
const setControl = (player: string, control: C) => ({ type: SET_CONTROL, payload: { player, control } })
|
|
48
|
+
|
|
49
|
+
function forget<T>(state: Record<string, T>, profileId: string): Record<string, T> {
|
|
50
|
+
if (!(profileId in state)) return state
|
|
51
|
+
const { [profileId]: _removed, ...rest } = state
|
|
52
|
+
return rest
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const settingsReducer = onProfileRemoved((state: PlayerSettingsState<S> = {}, action: UnknownAction): PlayerSettingsState<S> => {
|
|
56
|
+
if (action.type !== PATCH) return state
|
|
57
|
+
const { player, changes } = action.payload as ReturnType<typeof patch>['payload']
|
|
58
|
+
return { ...state, [player]: { ...state[player], ...changes } }
|
|
59
|
+
}, forget)
|
|
60
|
+
|
|
61
|
+
const controlsReducer = onProfileRemoved((state: PlayerControlsState<C> = {}, action: UnknownAction): PlayerControlsState<C> => {
|
|
62
|
+
if (action.type !== SET_CONTROL) return state
|
|
63
|
+
const { player, control } = action.payload as ReturnType<typeof setControl>['payload']
|
|
64
|
+
return state[player] === control ? state : { ...state, [player]: control }
|
|
65
|
+
}, forget)
|
|
66
|
+
|
|
67
|
+
// The settings the loadout shows and a round plays with, from the saved state: seat 1's player's
|
|
68
|
+
// own setup over the defaults, and each seat's player's controls (seat 2's only for a person).
|
|
69
|
+
function compose(saved: PlayerSettingsState<S> | undefined, controls: PlayerControlsState<C> | undefined, selection: Record<Seat, string | null>, seatTwoIsPerson: boolean): S {
|
|
70
|
+
const control1 = controls?.[seatPlayerKey(selection, 1)] ?? defaults.controlScheme[1]
|
|
71
|
+
let control2 = (seatTwoIsPerson ? controls?.[seatPlayerKey(selection, 2)] : undefined) ?? defaults.controlScheme[2]
|
|
72
|
+
if (distinctControls && control2 === control1) control2 = distinctControls.find((c) => c !== control1) ?? control2
|
|
73
|
+
return { ...defaults, ...saved?.[seatPlayerKey(selection, 1)], controlScheme: { 1: control1, 2: control2 } } as S
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The same, read straight from the store (a game's How to Play reads it this way).
|
|
77
|
+
function select(state: ShellState): S {
|
|
78
|
+
const root = state as unknown as Record<string, unknown>
|
|
79
|
+
return compose(root[settingsKey] as PlayerSettingsState<S> | undefined, root[controlsKey] as PlayerControlsState<C> | undefined, state.profileSelection, state.opponent.seatTwo === 'person')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The loadout's settings and their one write path. A patch's match settings go to seat 1's player;
|
|
83
|
+
// a changed control goes to that seat's player (seat 2's only for a person), so a swap of desktop
|
|
84
|
+
// keys between the seats saves both players' new keys.
|
|
85
|
+
function useSettings(): { settings: S; setSettings: (patch: Partial<S>) => void } {
|
|
86
|
+
const selection = useSelector((state: ShellState) => state.profileSelection)
|
|
87
|
+
const seatTwoIsPerson = useSelector((state: ShellState) => state.opponent.seatTwo === 'person')
|
|
88
|
+
const saved = useSelector((state: Record<string, unknown>) => state[settingsKey] as PlayerSettingsState<S> | undefined)
|
|
89
|
+
const controls = useSelector((state: Record<string, unknown>) => state[controlsKey] as PlayerControlsState<C> | undefined)
|
|
90
|
+
const dispatch = useDispatch()
|
|
91
|
+
|
|
92
|
+
const settings = useMemo(() => compose(saved, controls, selection, seatTwoIsPerson), [saved, controls, selection, seatTwoIsPerson])
|
|
93
|
+
|
|
94
|
+
const setSettings = useCallback(
|
|
95
|
+
(next: Partial<S>) => {
|
|
96
|
+
const { controlScheme, ...changes } = next
|
|
97
|
+
if (Object.keys(changes).length > 0) dispatch(patch(seatPlayerKey(selection, 1), changes as Partial<Omit<S, 'controlScheme'>>))
|
|
98
|
+
if (!controlScheme) return
|
|
99
|
+
for (const seat of SEATS) {
|
|
100
|
+
if (seat === 2 && !seatTwoIsPerson) continue
|
|
101
|
+
if (controlScheme[seat] !== settings.controlScheme[seat]) dispatch(setControl(seatPlayerKey(selection, seat), controlScheme[seat]))
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[dispatch, selection, seatTwoIsPerson, settings.controlScheme]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return { settings, setSettings }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `reducers` is what a game mounts (its GameDefinition's reducers); the two are also named for tests.
|
|
111
|
+
return { reducers: { [settingsKey]: settingsReducer, [controlsKey]: controlsReducer }, settingsReducer, controlsReducer, actions: { patch, setControl }, compose, select, useSettings }
|
|
112
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createSettingsSlice } from '@rific/core'
|
|
2
|
+
import type { ViewRotation } from '@tastic/core'
|
|
3
|
+
|
|
4
|
+
// Settings that apply to the whole pack rather than one game. A game's own rules (board, score to
|
|
5
|
+
// win, controls) live in that game's own settings slice.
|
|
6
|
+
export interface AppSettings {
|
|
7
|
+
// Lock Orientation: RootLayout's OrientationBridge hands it to @tastic/core's OrientationProvider
|
|
8
|
+
// (freezeWhileLocked), which freezes the one shared orientation reading for every screen, every
|
|
9
|
+
// board, dialogs and the status bar alike. Off by default: all of those just follow however the
|
|
10
|
+
// device is actually being held.
|
|
11
|
+
lockOrientation: boolean
|
|
12
|
+
// The angle the lock is holding. Written by OrientationProvider's onLockChange (recorded on lock,
|
|
13
|
+
// cleared on unlock) and handed back as lockInitialValue, so a relaunch reopens in the locked
|
|
14
|
+
// orientation instead of portrait. Never set by the UI directly; absent while unlocked.
|
|
15
|
+
lockedRotation?: ViewRotation
|
|
16
|
+
// Edge Guard's opt-in (see Providers.tsx's EdgeGuardBridge).
|
|
17
|
+
deferBottomEdgeGestures: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
|
21
|
+
lockOrientation: false,
|
|
22
|
+
deferBottomEdgeGestures: false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Merge mode, like every settings slice in the pack: REHYDRATE fills any field added since a
|
|
26
|
+
// player's settings were saved from the defaults.
|
|
27
|
+
const { actions, reducer } = createSettingsSlice<AppSettings, 'merge', never>('appSettings', {
|
|
28
|
+
initialState: DEFAULT_APP_SETTINGS,
|
|
29
|
+
initializeMode: 'merge',
|
|
30
|
+
fieldSetters: []
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const appSettingsActions = actions
|
|
34
|
+
export default reducer
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
2
|
+
import { combineReducers, configureStore, type Middleware, type Reducer } from '@reduxjs/toolkit'
|
|
3
|
+
import { FLUSH, PAUSE, PERSIST, type PersistedState, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'
|
|
4
|
+
|
|
5
|
+
import type { GameDefinition, PackDefinition } from '../types'
|
|
6
|
+
import lastActiveGame from './lastActiveGameSlice'
|
|
7
|
+
import { createShellReducers } from './shellState'
|
|
8
|
+
|
|
9
|
+
const hasError = (action: unknown): action is { error?: unknown } => typeof action === 'object' && action !== null && 'error' in action && Boolean(action.error)
|
|
10
|
+
|
|
11
|
+
const errorMiddleware: Middleware = () => (next) => (action) => {
|
|
12
|
+
if (!hasError(action)) return next(action)
|
|
13
|
+
return action
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PackStoreConfig {
|
|
17
|
+
// On web every Tastic app shares one origin (games.infinitetoken.com) and so one localStorage,
|
|
18
|
+
// where AsyncStorage keeps this state under keyPrefix + 'root': the default prefix would make every
|
|
19
|
+
// app's state the same persist:root. REHYDRATE still reports key 'root'. It applies on native too.
|
|
20
|
+
// This is the only key the pack writes itself (the shared profile roster is @tastic/profile's,
|
|
21
|
+
// shared on purpose).
|
|
22
|
+
keyPrefix: string
|
|
23
|
+
// redux-persist's migrate, run on the saved store before it rehydrates (see pack.ts).
|
|
24
|
+
migrate?: (state: PersistedState, version: number) => Promise<PersistedState>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// What the store is built from: the pack's games (each mounts its own keys, GameDefinition.reducers,
|
|
28
|
+
// and whether one is resumable decides the resume key), the pack's own keys (PackDefinition.reducers),
|
|
29
|
+
// and the seat colors and appearance the shell's own keys start from.
|
|
30
|
+
export type PackStoreSource = Pick<PackDefinition, 'seatColors' | 'appearance' | 'reducers'> & { games: readonly Pick<GameDefinition, 'reducers' | 'resumable'>[] }
|
|
31
|
+
|
|
32
|
+
// The pack's one Redux store, persisted with redux-persist: the shell's keys (shellState.ts), the
|
|
33
|
+
// pack's own, and every game's own.
|
|
34
|
+
export function createPackStore(pack: PackStoreSource, { keyPrefix, migrate }: PackStoreConfig) {
|
|
35
|
+
const gameReducers: Record<string, Reducer> = Object.assign({}, ...pack.games.map((game) => game.reducers))
|
|
36
|
+
// Which play screen the player was last on, for a cold launch to resume (GameDefinition.resumable).
|
|
37
|
+
// Only in a pack with a resumable game, so a pack without one saves exactly what it did before.
|
|
38
|
+
const resumeReducers: Record<string, Reducer> = pack.games.some((game) => game.resumable) ? { lastActiveGame } : {}
|
|
39
|
+
const rootReducer = combineReducers({ ...createShellReducers(pack), ...resumeReducers, ...pack.reducers, ...gameReducers })
|
|
40
|
+
|
|
41
|
+
const persistedReducer = persistReducer(
|
|
42
|
+
{
|
|
43
|
+
key: 'root',
|
|
44
|
+
keyPrefix,
|
|
45
|
+
storage: AsyncStorage,
|
|
46
|
+
// redux-persist defaults `timeout` to 5000ms: a failsafe setTimeout scheduled on every PERSIST
|
|
47
|
+
// dispatch to force-resolve rehydrate if storage never responds. It's never cleared once
|
|
48
|
+
// rehydrate resolves normally (only guarded by an internal `_sealed` flag), so it sits as a
|
|
49
|
+
// pending timer for up to 5s after every store creation - under Jest that's a real open handle
|
|
50
|
+
// ("A worker process has failed to exit gracefully"). Disabling it (falsy timeout skips the
|
|
51
|
+
// setTimeout call entirely) is redux-persist's own documented way to opt out; AsyncStorage reads
|
|
52
|
+
// failing to ever resolve at all isn't a failure mode worth a 5s failsafe for.
|
|
53
|
+
timeout: 0,
|
|
54
|
+
// liveplay is per-round state that must never survive a relaunch (see liveplaySlice.ts).
|
|
55
|
+
blacklist: ['liveplay'],
|
|
56
|
+
migrate
|
|
57
|
+
},
|
|
58
|
+
rootReducer
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const store = configureStore({
|
|
62
|
+
middleware: (getDefaultMiddleware) => {
|
|
63
|
+
const defaultMiddleware = getDefaultMiddleware({
|
|
64
|
+
immutableCheck: false,
|
|
65
|
+
serializableCheck: {
|
|
66
|
+
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
return defaultMiddleware.concat(errorMiddleware)
|
|
70
|
+
},
|
|
71
|
+
reducer: persistedReducer
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const persistor = persistStore(store)
|
|
75
|
+
return { store, persistor }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type PackStore = ReturnType<typeof createPackStore>['store']
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Action } from 'redux'
|
|
2
|
+
|
|
3
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
4
|
+
|
|
5
|
+
export interface GameGuideState {
|
|
6
|
+
versionSeen: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Which version of one game's How to Play cards this player has finished or skipped, under the
|
|
10
|
+
// game's own store key. Unlike @tastic/hud's createGuideSlice, a saved store without this key reads
|
|
11
|
+
// as "not seen": a game added to a pack in an update is new to everyone, so its cards show on the
|
|
12
|
+
// first tap of its tile even for a player who has had the pack for months. (createGuideSlice reads
|
|
13
|
+
// the missing key as a returning player and stamps it seen, which is right only for a guide that
|
|
14
|
+
// ships with the app's first store.)
|
|
15
|
+
export function createGameGuideSlice(mountKey: string) {
|
|
16
|
+
const MARK_SEEN = `${mountKey}/markSeen`
|
|
17
|
+
const initialState: GameGuideState = { versionSeen: 0 }
|
|
18
|
+
|
|
19
|
+
function markSeen(version: number) {
|
|
20
|
+
return { type: MARK_SEEN, payload: version }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function reducer(state: GameGuideState = initialState, action: Action & { payload?: unknown }): GameGuideState {
|
|
24
|
+
if (action.type === REHYDRATE) {
|
|
25
|
+
const persisted = (action.payload as Record<string, { versionSeen?: unknown } | undefined> | undefined)?.[mountKey]
|
|
26
|
+
return typeof persisted?.versionSeen === 'number' ? { versionSeen: persisted.versionSeen } : state
|
|
27
|
+
}
|
|
28
|
+
if (action.type === MARK_SEEN && typeof action.payload === 'number') {
|
|
29
|
+
return { versionSeen: Math.max(state.versionSeen, action.payload) }
|
|
30
|
+
}
|
|
31
|
+
return state
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function selectVersionSeen(rootState: object): number {
|
|
35
|
+
return (rootState as Record<string, GameGuideState | undefined>)[mountKey]?.versionSeen ?? 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { actions: { markSeen }, reducer, selectVersionSeen }
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createSettingsSlice } from '@rific/core'
|
|
2
|
+
|
|
3
|
+
// The play screen the player was last on, so a cold launch can drop them straight back onto it (see
|
|
4
|
+
// GameDefinition.resumable). Read once and written only through hooks/useLastActiveGame.tsx, which
|
|
5
|
+
// deliberately never subscribes to it (see that file for why). Mounted only in a pack with a
|
|
6
|
+
// resumable game (createPackStore.ts). From Solitaire's own, which keeps its game ids.
|
|
7
|
+
export type LastActiveGameState = { gameId: string | null }
|
|
8
|
+
|
|
9
|
+
const { actions, reducer } = createSettingsSlice<LastActiveGameState>('lastActiveGame', { initialState: { gameId: null } })
|
|
10
|
+
|
|
11
|
+
export const lastActiveGameActions = actions
|
|
12
|
+
export default reducer
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createSettingsSlice } from '@rific/core'
|
|
2
|
+
|
|
3
|
+
import type { RoundMode } from '../types'
|
|
4
|
+
|
|
5
|
+
// The round a loadout committed: which game, how seat 2 was set up, and a snapshot of that game's
|
|
6
|
+
// settings, so changing a setting mid-match only affects the next match. The game's play screen
|
|
7
|
+
// reads its own settings back out (see useActiveRound).
|
|
8
|
+
export interface ActiveRound {
|
|
9
|
+
game: string
|
|
10
|
+
mode: RoundMode
|
|
11
|
+
settings: object
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Transient per-round state, blacklisted from redux-persist in createPackStore.ts: none of it may
|
|
15
|
+
// survive a relaunch. A crash or force-quit mid-round leaving `activelyPlaying` stuck true in
|
|
16
|
+
// storage would re-enable Edge Guard (see Providers.tsx's EdgeGuardBridge) the moment the app
|
|
17
|
+
// cold-launches back to Home, before any play screen could correct it. Built on @rific/core's
|
|
18
|
+
// createSettingsSlice: one generated setter per field.
|
|
19
|
+
export type LiveplaySliceState = {
|
|
20
|
+
// Whether a play screen has a live, unpaused round in progress right now (not merely "a play
|
|
21
|
+
// screen is mounted"). Written by the play screen as its phase changes, and reset to false on its
|
|
22
|
+
// unmount so leaving mid-round by any path can't leave it stuck true.
|
|
23
|
+
activelyPlaying: boolean
|
|
24
|
+
// null until a round has been committed (a direct link to a play screen falls back to the game's
|
|
25
|
+
// defaults and a CPU opponent).
|
|
26
|
+
activeRound: ActiveRound | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { actions, reducer } = createSettingsSlice<LiveplaySliceState>('liveplay', {
|
|
30
|
+
initialState: { activelyPlaying: false, activeRound: null }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const liveplayActions = actions
|
|
34
|
+
export default reducer
|