@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.
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,132 @@
1
+ import type { SeedColor } from '@rific/auto-paper'
2
+ import { type MenuOption, type PopoverHost, ReadyButton, useZoneClampedAlign } from '@tastic/hud'
3
+ import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
4
+ import { ReactNode, useMemo } from 'react'
5
+ import { StyleSheet, View } from 'react-native'
6
+
7
+ import type { SeatTwo } from '../types'
8
+
9
+ // Seat 2's one button: who sits there (a CPU at a difficulty, nobody where the game has a solo
10
+ // mode, a guest, or a profile) and, on top, the seat's Color row. It replaces the choice between
11
+ // One Player and Two Player that Home used to ask before the loadout: picking a person here is what
12
+ // makes the round 2 Player, and the loadout turns to face both players.
13
+ //
14
+ // Built on @tastic/profile's OptionPicker, so it opens, places itself and closes like seat 1's
15
+ // ProfilePicker beside it; written so it can move into @tastic/hud's PlayerSetupPanel once the pack
16
+ // layout's shell moves into the shared packages (see the PuckTastic plan). Laid out like
17
+ // PlayerSetupPanel: the seat button, a person's controls picker under it, and their Ready button.
18
+
19
+ const CPU_PREFIX = 'cpu:'
20
+ const PROFILE_PREFIX = 'profile:'
21
+ const NOBODY = 'nobody'
22
+ const GUEST = 'guest'
23
+
24
+ const GUEST_ICON = 'account-outline'
25
+ const NOBODY_ICON = 'account-off-outline'
26
+ const CPU_ICON = 'robot'
27
+
28
+ export function getSeatTwoPopoverId(idPrefix: string): string {
29
+ return `${idPrefix}-seat`
30
+ }
31
+
32
+ export interface SeatTwoPanelProps {
33
+ idPrefix: string
34
+ host: PopoverHost
35
+ // Seat 2's choices in this game (GameDefinition.seatTwo), in menu order.
36
+ choices: readonly SeatTwo[]
37
+ seatTwo: SeatTwo
38
+ onSeatTwoChange: (seatTwo: SeatTwo) => void
39
+ // The CPU's levels (GameLoadout.cpuDifficulty), or undefined for a CPU with none: one CPU row then.
40
+ cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
41
+ profiles: Profile[]
42
+ selectedProfileId: string | null
43
+ // Seat 1's profile: picking it here swaps the two seats (see @tastic/profile's select).
44
+ onProfileSelect: (profileId: string | null) => void
45
+ color: string
46
+ onColorChange: (hex: string) => void
47
+ swatches: SeedColor[]
48
+ takenColor?: string
49
+ allowSwapTaken?: boolean
50
+ // Shown under the Color row while a profile is in the seat (its pick is kept for this pack only).
51
+ colorCaption?: string
52
+ dark: boolean
53
+ // A person's controls picker (see GameLoadout.seatControl).
54
+ secondPicker?: ReactNode
55
+ secondPickerId?: string
56
+ ready?: boolean
57
+ onToggleReady?: () => void
58
+ showReadyButton?: boolean
59
+ }
60
+
61
+ export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }: SeatTwoPanelProps) {
62
+ const isPerson = seatTwo === 'person'
63
+ const selectedProfile = isPerson ? (profiles.find((p) => p.id === selectedProfileId) ?? null) : null
64
+
65
+ const cpuOptions = cpuDifficulty?.options
66
+ const options = useMemo<OptionPickerOption<string>[]>(() => {
67
+ const list: OptionPickerOption<string>[] = []
68
+ if (choices.includes('cpu')) list.push(...(cpuOptions ? cpuOptions.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon })) : [{ value: CPU_PREFIX, label: 'CPU', icon: CPU_ICON }]))
69
+ if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Nobody', icon: NOBODY_ICON })
70
+ if (choices.includes('person')) {
71
+ list.push({ value: GUEST, label: 'Player 2', icon: GUEST_ICON })
72
+ list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: p.color })))
73
+ }
74
+ return list
75
+ }, [choices, cpuOptions, profiles])
76
+
77
+ const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ''}` : seatTwo === 'nobody' ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST
78
+ const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : selectedProfile ? 'account' : GUEST_ICON
79
+
80
+ const handleChange = (next: string) => {
81
+ if (next.startsWith(CPU_PREFIX)) {
82
+ cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length))
83
+ onSeatTwoChange('cpu')
84
+ } else if (next === NOBODY) {
85
+ onSeatTwoChange('nobody')
86
+ } else {
87
+ onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null)
88
+ onSeatTwoChange('person')
89
+ }
90
+ }
91
+
92
+ // Nobody has no color to pick: there's nothing of theirs on the board.
93
+ const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined }
94
+
95
+ const pickerId = getSeatTwoPopoverId(idPrefix)
96
+ const size = getOptionPickerContentSize(options.length, colorSection)
97
+ // Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button.
98
+ const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height)
99
+
100
+ const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false
101
+ const pickerRowOpen = secondPickerId !== undefined && host.openId === secondPickerId
102
+
103
+ return (
104
+ <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
105
+ <OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={color} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} />
106
+ {isPerson && secondPicker && <View style={[styles.pickerRow, pickerRowOpen && styles.pickerRowOpen]}>{secondPicker}</View>}
107
+ {isPerson && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
108
+ </View>
109
+ )
110
+ }
111
+
112
+ // The same rhythm and stacking as @tastic/hud's PlayerSetupPanel, so seat 2 lines up with seat 1.
113
+ const styles = StyleSheet.create({
114
+ panel: {
115
+ alignItems: 'center',
116
+ gap: 12
117
+ },
118
+ // A popover escaping this panel needs the panel itself elevated to paint above a later sibling.
119
+ panelOpen: {
120
+ zIndex: 100
121
+ },
122
+ pickerRow: {
123
+ alignItems: 'center',
124
+ flexDirection: 'row',
125
+ gap: 12
126
+ },
127
+ // Lifts the controls picker's popover above the Ready button below it (each View is its own
128
+ // stacking context on the web).
129
+ pickerRowOpen: {
130
+ zIndex: 100
131
+ }
132
+ })
@@ -0,0 +1,42 @@
1
+ import { useUpdateErrorToast } from '@rific/toaster'
2
+ import { ViewRotation } from '@tastic/core'
3
+ import { BaseSettingsDialog } from '@tastic/hud'
4
+ import type { ReactNode } from 'react'
5
+
6
+ import { useAppSettings } from '../hooks/useAppSettings'
7
+ import { usePack } from '../PackContext'
8
+
9
+ export interface SettingsDialogProps {
10
+ visible: boolean
11
+ onDismiss: () => void
12
+ // Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet
13
+ // out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up
14
+ // and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't
15
+ // track one.
16
+ rotation?: ViewRotation
17
+ // Shows the How to Play row, which opens this game's cards (a loadout passes its game's opener).
18
+ // Left out on Home, where each tile's rules button does it, and in a match, where opening
19
+ // Settings pauses play.
20
+ onShowHowToPlay?: () => void
21
+ // A play screen's own rows (a game's rule variants, where it has no loadout to set them in, see
22
+ // GameDefinition.useLoadout), above the pack's own sections.
23
+ children?: ReactNode
24
+ }
25
+
26
+ // The pack-wide Settings dialog: everything the shell puts in it applies to every game (Lock
27
+ // Orientation, Edge Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app
28
+ // settings itself. Under those, a play screen's own rows and then the pack's own sections
29
+ // (PackDefinition.SettingsSections), in every Settings dialog the pack opens.
30
+ export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }: SettingsDialogProps) {
31
+ const onUpdateError = useUpdateErrorToast()
32
+ const { settings, setSettings } = useAppSettings()
33
+ // The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
34
+ const { otaVersion, SettingsSections } = usePack()
35
+
36
+ return (
37
+ <BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} deferBottomEdgeGestures={settings.deferBottomEdgeGestures} onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
38
+ {children}
39
+ {SettingsSections && <SettingsSections />}
40
+ </BaseSettingsDialog>
41
+ )
42
+ }
@@ -0,0 +1,75 @@
1
+ import MaskedView from '@react-native-masked-view/masked-view'
2
+ import { Provider as AutoPaperProvider, themeActions, ThemeSettings, useThemeBridgeProps } from '@rific/auto-paper'
3
+ import * as ExpoBlur from 'expo-blur'
4
+ import * as SplashScreen from 'expo-splash-screen'
5
+ import { ReactNode, useCallback } from 'react'
6
+ import Reanimated from 'react-native-reanimated'
7
+ import { shallowEqual, useDispatch, useSelector } from 'react-redux'
8
+
9
+ import { usePack } from '../PackContext'
10
+ import type { ShellState } from '../redux/shellState'
11
+ import { markSplashReady } from '../splashGate'
12
+
13
+ SplashScreen.preventAutoHideAsync()
14
+ // iOS defaults `fade` to false (Android always fades regardless of this flag), so without this the
15
+ // splash view is just yanked off screen the instant every gate reports ready.
16
+ SplashScreen.setOptions({ fade: true, duration: 400 })
17
+
18
+ interface Props {
19
+ children: ReactNode
20
+ }
21
+
22
+ export function Theme({ children }: Props) {
23
+ const { fontFamily } = usePack()
24
+ // redux/store.ts's own `theme` slice (createThemeReducer, seeded with this app's real default
25
+ // P1/P2 colors - see constants/game.ts) replaces this component's old per-field AsyncStorage
26
+ // load/save entirely. PersistGate (mounted in Providers.tsx, an ancestor of this component -
27
+ // Theme is rendered as its child in _layout.tsx) already blocks rendering until redux-persist has
28
+ // rehydrated, so by the time this mounts `settings` is already the real persisted (or, on a fresh
29
+ // install, default) triad - no async gate/loading state needed here the way the old AsyncStorage
30
+ // version required.
31
+ const settings = useSelector((state: ShellState) => state.theme, shallowEqual)
32
+ const dispatch = useDispatch()
33
+
34
+ // A one-shot callback, not a boolean, so there's nothing for useSplashReady to watch - PersistGate
35
+ // (an ancestor, see Providers.tsx) already blocks this component from mounting until redux-persist
36
+ // has rehydrated, so `settings` above is already the real persisted value the instant Theme mounts.
37
+ // That's necessary but not sufficient, though: AutoPaperProvider itself renders null internally
38
+ // until it's actually computed a theme object from that settings value (see its own source), which
39
+ // is a separate, later step from Theme just mounting with valid settings in hand. Marking this gate
40
+ // ready unconditionally at mount - as this file used to - could let the splash screen lift while
41
+ // AutoPaperProvider was still rendering null underneath it, same class of bug this app's own
42
+ // splash-gate audit found elsewhere. This instead marks the 'theme' gate (see
43
+ // src/utils/splashGate.ts) only once AutoPaperProvider itself reports a real theme, matching every
44
+ // sibling app's own Theme.tsx.
45
+ const onReady = useCallback(() => markSplashReady('theme'), [])
46
+
47
+ // Persists the FULL settings object now (appearance included), not just appearance - color used
48
+ // to be excluded here and persisted separately by loadout.tsx's own saveGuestColors/saveCpuColor
49
+ // AsyncStorage calls; now that both live in the same `theme` Redux slice, one unconditional
50
+ // dispatch covers all of it. loadout.tsx's own useThemeSettings().set(...) calls (unchanged by
51
+ // this) are what drive the LIVE color moment to moment; this onChange is only what makes whatever
52
+ // that resolves to durable across a relaunch.
53
+ const onChange = useCallback((next: ThemeSettings) => dispatch(themeActions.initialize(next)), [dispatch])
54
+
55
+ const bridgeProps = useThemeBridgeProps({ initialValue: settings, onChange, onReady })
56
+
57
+ // expoBlur is what lets auto-paper's BlurView actually blur (the settings sheet, Paper menus and
58
+ // @tastic/hud's overlay cards) while the player's Blur setting is on. Without it they paint the
59
+ // theme's solid surface instead, and hud's Settings hides the Blur row.
60
+ // maskedView is what lets auto-paper's FadingScrollView fade scrolling rows out at their edges on
61
+ // iOS/Android (the Settings sheet's and Rules' rows above Done, @tastic/profile's dropdown lists
62
+ // above Manage); without it they just scroll with a hard edge. Web needs nothing.
63
+ // statusBar={false}: auto-paper renders no React Native StatusBar (here or in Appbar.Header). The
64
+ // status bar is set per screen (_layout.tsx's useRotationAwareStatusBar), and with @tastic/core's
65
+ // config plugin on, a mounted StatusBar logs an error (a red box in a dev build).
66
+ // reanimated is what makes Dialog's own animatedStyle prop do anything (Metro won't pick up a
67
+ // require()-in-try/catch from this package's ESM build, so the module is handed in); nothing else in
68
+ // auto-paper reads it. fontFamily is the pack's (PackDefinition.fontFamily): set, it goes on every
69
+ // Paper typography variant; unset, Paper keeps its own.
70
+ return (
71
+ <AutoPaperProvider {...bridgeProps} expoBlur={ExpoBlur} maskedView={MaskedView} statusBar={false} reanimated={Reanimated} fontFamily={fontFamily}>
72
+ {children}
73
+ </AutoPaperProvider>
74
+ )
75
+ }
@@ -0,0 +1,13 @@
1
+ import { useUpdateErrorToast } from '@rific/toaster'
2
+ import { useRotation } from '@tastic/core'
3
+ import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud'
4
+
5
+ // Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
6
+ // instance, the manifest/confirm bridging, and the themed ConfirmDialog rendering this file used
7
+ // to hand-roll itself. Kept as its own named export (rather than importing SharedUpdateDialog
8
+ // directly in _layout.tsx) purely so this app's own onError -> toast wiring has one place to live.
9
+ export function UpdateDialog() {
10
+ const onError = useUpdateErrorToast()
11
+ const rotation = useRotation()
12
+ return <SharedUpdateDialog onError={onError} rotation={rotation} />
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { ComponentType } from 'react'
2
+ import { Image, type ImageSourcePropType } from 'react-native'
3
+
4
+ import type { GameIconProps } from '../types'
5
+
6
+ // A game icon drawn as a light and dark pair of pictures (opaque squares, the art drawn as an app
7
+ // icon) rather than in the seats' colors: GameDefinition.icon for a game whose art is a picture.
8
+ // Home clips it to the row's rounded icon square.
9
+ export function imageGameIcon(images: { light: ImageSourcePropType; dark: ImageSourcePropType }): ComponentType<GameIconProps> {
10
+ return function ImageGameIcon({ size, dark }: GameIconProps) {
11
+ return <Image source={dark ? images.dark : images.light} style={{ height: size, width: size }} accessibilityIgnoresInvertColors />
12
+ }
13
+ }
@@ -0,0 +1,90 @@
1
+ import { type AchievementDefinition, type AchievementsState, createAchievementsSlice, mapUnlocksBySeat, type UnlockedAchievementsState, useAchievements } from '@tastic/achievements'
2
+ import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react'
3
+
4
+ import { forgetProfileAchievements, onProfileRemoved } from './redux/onProfileRemoved'
5
+ import type { Seat } from './types'
6
+
7
+ const SEATS: readonly Seat[] = [1, 2]
8
+
9
+ // What a recorded result unlocked: device-wide, and per seat for each seat with a profile in it (the
10
+ // package keys per-profile unlocks by profile id; the games think in seats).
11
+ export interface RecordResult<TStats> {
12
+ device: AchievementDefinition<TStats>[]
13
+ profiles: Partial<Record<Seat, AchievementDefinition<TStats>[]>>
14
+ }
15
+
16
+ interface GameStatsContextValue<TStats> {
17
+ stats: TStats
18
+ unlockedAchievements: UnlockedAchievementsState
19
+ // Applies a result to the stats and returns what it unlocked. `profileIds` maps each seat to the
20
+ // profile in it, for the per-seat breakdown.
21
+ record: (update: (prev: TStats) => TStats, profileIds?: Partial<Record<Seat, string>>) => RecordResult<TStats>
22
+ // Wipes the game's stats and achievements back to defaults - irreversible, so callers confirm with
23
+ // the player first (the shell's stats screen reset).
24
+ resetAll: () => void
25
+ }
26
+
27
+ interface GameStatsOptions<TStats, TBucket> {
28
+ // The store key, which is also where the stats are saved.
29
+ key: string
30
+ defaultStats: TStats
31
+ isValidStats?: (value: unknown) => boolean
32
+ catalog: AchievementDefinition<TStats>[]
33
+ // A profile's own bucket seen through the same shape as the device-wide stats, so every
34
+ // achievement predicate reads either.
35
+ getProfileStatsView: (bucket: TBucket) => TStats
36
+ }
37
+
38
+ // One game's stats and achievements: its store slice (@tastic/achievements' createAchievementsSlice
39
+ // under the game's own key, with a profile deleted on the Profiles screen taking its stats bucket and
40
+ // unlocks with it), and the context every screen reads them through, mounted once at the app root
41
+ // (GameDefinition.Provider) so useAchievements' mount-time unlock re-sweep runs once. Every game keeps
42
+ // its stats with a `profiles` bucket per profile beside the device-wide totals.
43
+ export function createGameStats<TStats extends { profiles: Record<string, TBucket> }, TBucket>({ key, defaultStats, isValidStats, catalog, getProfileStatsView }: GameStatsOptions<TStats, TBucket>) {
44
+ const { actions, reducer: sliceReducer } = createAchievementsSlice<TStats>({
45
+ key,
46
+ defaultStats,
47
+ isValidStats,
48
+ // Backfills `profiles` onto stats saved without it.
49
+ migrateStats: (stored) => ({ ...stored, profiles: stored.profiles ?? {} })
50
+ })
51
+
52
+ const dropProfileStats = (stats: TStats, profileId: string): TStats => {
53
+ if (!(profileId in stats.profiles)) return stats
54
+ const { [profileId]: _removed, ...rest } = stats.profiles
55
+ return { ...stats, profiles: rest }
56
+ }
57
+ const reducer = onProfileRemoved(sliceReducer, forgetProfileAchievements<TStats, AchievementsState<TStats>>(dropProfileStats))
58
+
59
+ const profileViews = (stats: TStats): Record<string, TStats> => Object.fromEntries(Object.entries(stats.profiles).map(([profileId, bucket]) => [profileId, getProfileStatsView(bucket)]))
60
+
61
+ const Context = createContext<GameStatsContextValue<TStats> | null>(null)
62
+
63
+ function Provider({ children }: { children: ReactNode }) {
64
+ const { stats, unlockedAchievements, recordOutcome, resetAll } = useAchievements<TStats, Record<string, AchievementsState<TStats>>>({
65
+ catalog,
66
+ select: (state) => state[key],
67
+ actions,
68
+ profileViews
69
+ })
70
+
71
+ const record = useCallback(
72
+ (update: (prev: TStats) => TStats, profileIds?: Partial<Record<Seat, string>>): RecordResult<TStats> => {
73
+ const result = recordOutcome(update)
74
+ return { device: result.device, profiles: mapUnlocksBySeat(SEATS, profileIds, result.profiles) }
75
+ },
76
+ [recordOutcome]
77
+ )
78
+
79
+ const value = useMemo(() => ({ stats, unlockedAchievements, record, resetAll }), [stats, unlockedAchievements, record, resetAll])
80
+ return <Context.Provider value={value}>{children}</Context.Provider>
81
+ }
82
+
83
+ function useStats(): GameStatsContextValue<TStats> {
84
+ const ctx = useContext(Context)
85
+ if (!ctx) throw new Error(`The ${key} stats are read within their game's Provider`)
86
+ return ctx
87
+ }
88
+
89
+ return { reducer, actions, Provider, useStats }
90
+ }
@@ -0,0 +1,16 @@
1
+ import { useSelector } from 'react-redux'
2
+
3
+ import { usePackGame } from '../PackContext'
4
+ import type { ShellState } from '../redux/shellState'
5
+ import type { RoundMode } from '../types'
6
+
7
+ // The round a play screen is playing: committed by the loadout when every human player readied up
8
+ // (see liveplaySlice.ts). A play screen reached without one (a direct link, a web reload, or a game
9
+ // with no loadout, which Home opens straight away) plays its game's defaults: against the CPU, or
10
+ // solo in a game with no seat 2 (GameDefinition.seatTwo).
11
+ export function useActiveRound<S extends object>(game: string, defaults: S): { mode: RoundMode; settings: S } {
12
+ const round = useSelector((state: ShellState) => state.liveplay.activeRound)
13
+ const seated = !!usePackGame(game)?.seatTwo
14
+ if (!round || round.game !== game) return { mode: seated ? 'vsCpu' : 'solo', settings: defaults }
15
+ return { mode: round.mode, settings: { ...defaults, ...(round.settings as Partial<S>) } }
16
+ }
@@ -0,0 +1,19 @@
1
+ import { useCallback } from 'react'
2
+ import { useDispatch, useSelector } from 'react-redux'
3
+
4
+ import { type AppSettings, appSettingsActions } from '../redux/appSettingsSlice'
5
+ import type { ShellState } from '../redux/shellState'
6
+
7
+ // The one way the pack-wide settings are read and written (Lock Orientation, Edge Guard). Writes
8
+ // merge, so a patch only changes the fields it names.
9
+ export function useAppSettings(): { settings: AppSettings; setSettings: (patch: Partial<AppSettings>) => void } {
10
+ const settings = useSelector((state: ShellState) => state.appSettings)
11
+ const dispatch = useDispatch()
12
+ const setSettings = useCallback(
13
+ (patch: Partial<AppSettings>) => {
14
+ dispatch(appSettingsActions.initialize(patch))
15
+ },
16
+ [dispatch]
17
+ )
18
+ return { settings, setSettings }
19
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react'
2
+
3
+ // Returns `value`, except while `hidden` is true: then it keeps returning whatever `value` was the
4
+ // instant before `hidden` became true, rather than whatever `value` has already become. Ported from
5
+ // Solitaire's components/useFrozenWhileHidden.ts as it was.
6
+ //
7
+ // Home holds its layout with it while another screen covers it (screens/HomeScreen.tsx): Home stays
8
+ // mounted beneath every screen the stack pushes and keeps following the rotation, and crossing the
9
+ // upright/wide threshold swaps its whole tree (every tile, the hero, seat 1's button), invisible work
10
+ // landing in the same commit as the covering screen's own rotation update. While covered the layout is
11
+ // held where it was; it catches up (one swap, visible) when Home is shown again.
12
+ export function useFrozenWhileHidden<T>(value: T, hidden: boolean): T {
13
+ // React's own sanctioned "adjust state during render" shape (see react.dev's guide of the same
14
+ // name) rather than mutating a ref during render: each setter below is only ever called when the
15
+ // value it tracks has actually changed, so this settles within the same render pass instead of
16
+ // triggering a visible extra frame.
17
+ const [prevHidden, setPrevHidden] = useState(hidden)
18
+ const [prevValue, setPrevValue] = useState(value)
19
+ const [frozen, setFrozen] = useState(value)
20
+
21
+ if (hidden !== prevHidden) {
22
+ setPrevHidden(hidden)
23
+ // Freezes `prevValue` (last render's value, still whatever was visible a moment ago), not this
24
+ // render's `value`: hidden can flip true the same commit `value` already changed, and it's the
25
+ // outgoing one that should stay on screen.
26
+ if (hidden) setFrozen(prevValue)
27
+ }
28
+ if (value !== prevValue) {
29
+ setPrevValue(value)
30
+ }
31
+
32
+ return hidden ? frozen : value
33
+ }
@@ -0,0 +1,73 @@
1
+ import { createContext, ReactNode, useContext, useMemo, useRef, useState } from 'react'
2
+ import { useDispatch, useStore } from 'react-redux'
3
+
4
+ import { lastActiveGameActions } from '../redux/lastActiveGameSlice'
5
+ import type { ShellState } from '../redux/shellState'
6
+
7
+ interface LastActiveGameContextValue {
8
+ // Returns the game a cold launch should drop the player straight back onto (once), or null.
9
+ consumeResumeGame: () => string | null
10
+ setLastActiveGame: (gameId: string) => void
11
+ clearLastActiveGame: () => void
12
+ }
13
+
14
+ const LastActiveGameContext = createContext<LastActiveGameContextValue | null>(null)
15
+
16
+ // Which play screen the player is currently on, persisted (the store's `lastActiveGame` key) so a
17
+ // cold launch/relaunch can drop them straight back onto it (see screens/HomeScreen.tsx) instead of
18
+ // Home - independent of whether that game still has a resumable board, which is the game's own.
19
+ // Mounted inside PersistGate, so the value read once at mount is already the rehydrated one. Ported
20
+ // from Solitaire's stores/lastActiveGame.tsx as it was.
21
+ //
22
+ // Deliberately NOT React state, and never read with useSelector. The only thing that ever needed the
23
+ // live value was Home's resume Redirect, and that is a one-shot cold-launch decision - so exposing it
24
+ // as state made every set/clear (each game mount, each Home tap) re-render every consumer, and made
25
+ // every Home instance in the stack swap its whole menu tree for a Redirect and back (in Solitaire,
26
+ // tearing down and rebuilding ~370 SVG preview cards on each transition). The value handed out here
27
+ // is stable for the provider's whole lifetime; `pendingResume` is consumed by the first Home to mount
28
+ // and cleared by any set/clear, so a launch straight into a game (deep link) can never later resume a
29
+ // stale one. A pack with no resumable game has no `lastActiveGame` key, and this reads null.
30
+ export function LastActiveGameProvider({ children }: { children: ReactNode }) {
31
+ const store = useStore<ShellState>()
32
+ const dispatch = useDispatch()
33
+ // Read once, at mount (a lazy initializer, never a subscription).
34
+ const [initial] = useState(() => store.getState().lastActiveGame?.gameId ?? null)
35
+ const pendingResume = useRef<string | null>(initial)
36
+ // What the store currently holds, so an unchanged value is never rewritten: every game mount sets it
37
+ // and every exit clears it.
38
+ const persisted = useRef<string | null>(initial)
39
+
40
+ const value = useMemo<LastActiveGameContextValue>(
41
+ () => ({
42
+ consumeResumeGame: () => {
43
+ const game = pendingResume.current
44
+ pendingResume.current = null
45
+ return game
46
+ },
47
+ setLastActiveGame: (gameId) => {
48
+ pendingResume.current = null
49
+ if (persisted.current === gameId) return
50
+ persisted.current = gameId
51
+ dispatch(lastActiveGameActions.setGameId(gameId))
52
+ },
53
+ // Called from the play route's unmount cleanup (screens/GameRoute.tsx) - it fires on the play
54
+ // screen's own back, hardware back, or a browser back, but not on mere app backgrounding, where
55
+ // the screen stays mounted. That's exactly "explicitly back out."
56
+ clearLastActiveGame: () => {
57
+ pendingResume.current = null
58
+ if (persisted.current === null) return
59
+ persisted.current = null
60
+ dispatch(lastActiveGameActions.setGameId(null))
61
+ }
62
+ }),
63
+ [dispatch]
64
+ )
65
+
66
+ return <LastActiveGameContext.Provider value={value}>{children}</LastActiveGameContext.Provider>
67
+ }
68
+
69
+ export function useLastActiveGame(): LastActiveGameContextValue {
70
+ const ctx = useContext(LastActiveGameContext)
71
+ if (!ctx) throw new Error('useLastActiveGame must be used within a LastActiveGameProvider')
72
+ return ctx
73
+ }
@@ -0,0 +1,14 @@
1
+ import { useCallback } from 'react'
2
+ import { useDispatch, useSelector } from 'react-redux'
3
+
4
+ import { opponentActions } from '../redux/opponentSlice'
5
+ import type { ShellState } from '../redux/shellState'
6
+ import type { SeatTwo } from '../types'
7
+
8
+ // Who sits in seat 2 across the pack (see opponentSlice.ts).
9
+ export function useOpponent(): { seatTwo: SeatTwo; setSeatTwo: (seatTwo: SeatTwo) => void } {
10
+ const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
11
+ const dispatch = useDispatch()
12
+ const setSeatTwo = useCallback((next: SeatTwo) => dispatch(opponentActions.setSeatTwo(next)), [dispatch])
13
+ return { seatTwo, setSeatTwo }
14
+ }