@tastic/shell 0.0.0-stage → 0.1.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -2
  3. package/assets/sounds/notification.wav +0 -0
  4. package/assets/sounds/select.wav +0 -0
  5. package/dist/index.d.mts +487 -0
  6. package/dist/index.d.ts +487 -0
  7. package/dist/index.js +1636 -0
  8. package/dist/index.mjs +1562 -0
  9. package/dist/notification-CGGFPYK7.wav +0 -0
  10. package/dist/select-MLBNNA4N.wav +0 -0
  11. package/package.json +163 -4
  12. package/src/PackContext.tsx +22 -0
  13. package/src/RootLayout.tsx +186 -0
  14. package/src/components/Fonts.tsx +41 -0
  15. package/src/components/GuideHost.tsx +104 -0
  16. package/src/components/Providers.tsx +86 -0
  17. package/src/components/SeatTwoPanel.tsx +132 -0
  18. package/src/components/SettingsDialog.tsx +42 -0
  19. package/src/components/Theme.tsx +75 -0
  20. package/src/components/UpdateDialog.tsx +13 -0
  21. package/src/components/imageGameIcon.tsx +13 -0
  22. package/src/gameStats.tsx +90 -0
  23. package/src/hooks/useActiveRound.ts +16 -0
  24. package/src/hooks/useAppSettings.ts +19 -0
  25. package/src/hooks/useFrozenWhileHidden.ts +33 -0
  26. package/src/hooks/useLastActiveGame.tsx +73 -0
  27. package/src/hooks/useOpponent.ts +14 -0
  28. package/src/hooks/useProfiles.tsx +134 -0
  29. package/src/hooks/useSeatColors.ts +148 -0
  30. package/src/index.ts +39 -0
  31. package/src/navigation.ts +11 -0
  32. package/src/playerSettings.ts +112 -0
  33. package/src/redux/appSettingsSlice.ts +34 -0
  34. package/src/redux/createPackStore.ts +78 -0
  35. package/src/redux/gameGuideSlice.ts +39 -0
  36. package/src/redux/lastActiveGameSlice.ts +12 -0
  37. package/src/redux/liveplaySlice.ts +34 -0
  38. package/src/redux/onProfileRemoved.ts +25 -0
  39. package/src/redux/opponentSlice.ts +21 -0
  40. package/src/redux/profileSelectionSlice.ts +17 -0
  41. package/src/redux/seatColorsSlice.ts +24 -0
  42. package/src/redux/shellState.ts +59 -0
  43. package/src/screens/GameRoute.tsx +48 -0
  44. package/src/screens/HomeScreen.tsx +378 -0
  45. package/src/screens/LoadoutScreen.tsx +271 -0
  46. package/src/screens/ProfilesRoute.tsx +14 -0
  47. package/src/screens/StatsScreen.tsx +133 -0
  48. package/src/screens/homeLayout.ts +129 -0
  49. package/src/splashGate.ts +3 -0
  50. 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