@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
package/dist/index.mjs ADDED
@@ -0,0 +1,1562 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/PackContext.tsx
9
+ import { createContext, useContext } from "react";
10
+ import { jsx } from "react/jsx-runtime";
11
+ var PackContext = createContext(null);
12
+ function PackProvider({ pack, children }) {
13
+ return /* @__PURE__ */ jsx(PackContext.Provider, { value: pack, children });
14
+ }
15
+ function usePack() {
16
+ const pack = useContext(PackContext);
17
+ if (!pack) throw new Error("usePack must be used within a PackProvider");
18
+ return pack;
19
+ }
20
+ function usePackGame(id) {
21
+ return usePack().games.find((g) => g.id === id);
22
+ }
23
+
24
+ // src/RootLayout.tsx
25
+ import { useAutoPaperTheme } from "@rific/auto-paper";
26
+ import { useHapticSettings } from "@rific/feedback-press";
27
+ import { Toaster, ToastProvider } from "@rific/toaster";
28
+ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from "@tastic/core";
29
+ import { RotatedFrame } from "@tastic/hud";
30
+ import * as Haptics from "expo-haptics";
31
+ import { Stack } from "expo-router";
32
+ import { DeviceMotion } from "expo-sensors";
33
+ import { useCallback as useCallback6 } from "react";
34
+ import { StyleSheet } from "react-native";
35
+ import { GestureHandlerRootView } from "react-native-gesture-handler";
36
+ import * as RNPaper2 from "react-native-paper";
37
+ import { SafeAreaProvider } from "react-native-safe-area-context";
38
+
39
+ // src/components/Fonts.tsx
40
+ import { MaterialCommunityIcons } from "@expo/vector-icons";
41
+ import Constants from "expo-constants";
42
+ import { useFonts } from "expo-font";
43
+ import Head from "expo-router/head";
44
+ import { Platform } from "react-native";
45
+
46
+ // src/splashGate.ts
47
+ import { createGate } from "@rific/splash-gate";
48
+ var { markReady: markSplashReady, useReady: useSplashReady, pendingGates: pendingSplashGates, Gate: SplashGate } = createGate(["theme", "fonts", "profiles"]);
49
+
50
+ // src/components/Fonts.tsx
51
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
52
+ function Fonts({ children }) {
53
+ const pack = usePack();
54
+ const [loaded, error] = useFonts({ ...pack.fonts, ...MaterialCommunityIcons.font });
55
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
56
+ Platform.OS === "web" && /* @__PURE__ */ jsx2(Head, { children: /* @__PURE__ */ jsx2("title", { children: Constants.expoConfig?.name }) }),
57
+ /* @__PURE__ */ jsx2(SplashGate, { gate: "fonts", ready: loaded || !!error, children })
58
+ ] });
59
+ }
60
+
61
+ // src/components/GuideHost.tsx
62
+ import { useIsTouchPrimaryDevice, useRotation } from "@tastic/core";
63
+ import { GuideProvider, useGuide } from "@tastic/hud/guide";
64
+ import { createContext as createContext2, useCallback, useContext as useContext2, useEffect, useMemo, useRef, useState } from "react";
65
+ import { shallowEqual, useDispatch, useSelector, useStore } from "react-redux";
66
+
67
+ // src/types.ts
68
+ function roundModeFor(seatTwo) {
69
+ return seatTwo === "person" ? "twoPlayer" : seatTwo === "nobody" ? "solo" : "vsCpu";
70
+ }
71
+ function seatTwoFor(saved, choices) {
72
+ if (!choices) return "nobody";
73
+ return choices.includes(saved) ? saved : "cpu";
74
+ }
75
+ function humanSeatsFor(mode) {
76
+ return mode === "twoPlayer" ? [1, 2] : [1];
77
+ }
78
+
79
+ // src/components/GuideHost.tsx
80
+ import { jsx as jsx3 } from "react/jsx-runtime";
81
+ var GameGuideContext = createContext2(null);
82
+ function GuideHost({ children }) {
83
+ const pack = usePack();
84
+ const [gameId, setGameId] = useState(pack.games[0].id);
85
+ const game = pack.games.find((g) => g.id === gameId) ?? pack.games[0];
86
+ const rotation = useRotation();
87
+ const touch = useIsTouchPrimaryDevice();
88
+ const seatTwo = useSelector((state) => state.opponent.seatTwo);
89
+ const input = useSelector((state) => game.guide.selectInput(state), shallowEqual);
90
+ const seenVersion = useSelector((state) => game.guide.selectVersionSeen(state));
91
+ const dispatch = useDispatch();
92
+ const mode = roundModeFor(seatTwoFor(seatTwo, game.seatTwo));
93
+ const steps = useMemo(() => game.guide.getSteps(input, { touch, mode }), [game, input, touch, mode]);
94
+ const onSeen = useCallback((version) => dispatch(game.guide.markSeen(version)), [dispatch, game]);
95
+ return /* @__PURE__ */ jsx3(GuideProvider, { steps, currentVersion: game.guide.version, seenVersion, onSeen, rotation, children: /* @__PURE__ */ jsx3(GameGuideBridge, { onPick: setGameId, children }) });
96
+ }
97
+ function GameGuideBridge({ onPick, children }) {
98
+ const pack = usePack();
99
+ const store = useStore();
100
+ const { isOpen, open } = useGuide();
101
+ const pendingNext = useRef(null);
102
+ const wasOpen = useRef(false);
103
+ useEffect(() => {
104
+ if (wasOpen.current && !isOpen && pendingNext.current) {
105
+ const next = pendingNext.current;
106
+ pendingNext.current = null;
107
+ next();
108
+ }
109
+ wasOpen.current = isOpen;
110
+ }, [isOpen]);
111
+ const openFor = useCallback(
112
+ (gameId) => {
113
+ onPick(gameId);
114
+ open();
115
+ },
116
+ [onPick, open]
117
+ );
118
+ const openFirstTime = useCallback(
119
+ (gameId, next) => {
120
+ const game = pack.games.find((g) => g.id === gameId);
121
+ if (!game || game.guide.selectVersionSeen(store.getState()) >= game.guide.version) {
122
+ next();
123
+ return;
124
+ }
125
+ pendingNext.current = next;
126
+ openFor(gameId);
127
+ },
128
+ [pack, store, openFor]
129
+ );
130
+ const value = useMemo(() => ({ openFirstTime, openFor }), [openFirstTime, openFor]);
131
+ return /* @__PURE__ */ jsx3(GameGuideContext.Provider, { value, children });
132
+ }
133
+ function useGameGuide() {
134
+ const ctx = useContext2(GameGuideContext);
135
+ if (!ctx) throw new Error("useGameGuide must be used within a GuideHost");
136
+ return ctx;
137
+ }
138
+
139
+ // src/components/Providers.tsx
140
+ import { keepAwakeActions, KeepAwakeProvider } from "@rific/core";
141
+ import { FeedbackPressProvider, hapticActions, soundActions, useFeedbackBridgeProps } from "@rific/feedback-press";
142
+ import { useAudioPool } from "@rific/feedback-press/audio";
143
+ import { useEdgeGestureGuard } from "@tastic/edge-guard";
144
+ import * as ExpoKeepAwake from "expo-keep-awake";
145
+ import { useCallback as useCallback2 } from "react";
146
+ import * as RNPaper from "react-native-paper";
147
+ import { Provider as ReduxProvider, useDispatch as useDispatch2, useSelector as useSelector2 } from "react-redux";
148
+ import { PersistGate } from "redux-persist/integration/react";
149
+ import { Fragment as Fragment2, jsx as jsx4 } from "react/jsx-runtime";
150
+ var FeedbackBridge = ({ children }) => {
151
+ const haptic = useSelector2((state) => state.haptic);
152
+ const sound = useSelector2((state) => state.sound);
153
+ const dispatch = useDispatch2();
154
+ const onChange = useCallback2((next) => dispatch(hapticActions.initialize(next)), [dispatch]);
155
+ const onSoundChange = useCallback2((next) => dispatch(soundActions.initialize(next)), [dispatch]);
156
+ const playSelection = useAudioPool(__require("./select-MLBNNA4N.wav"));
157
+ const playNotification = useAudioPool(__require("./notification-CGGFPYK7.wav"));
158
+ const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: { selection: playSelection, notification: playNotification } });
159
+ return /* @__PURE__ */ jsx4(FeedbackPressProvider, { ...bridgeProps, paper: RNPaper, children });
160
+ };
161
+ var EdgeGuardBridge = ({ children }) => {
162
+ const deferBottomEdgeGestures = useSelector2((state) => state.appSettings.deferBottomEdgeGestures);
163
+ const activelyPlaying = useSelector2((state) => state.liveplay.activelyPlaying);
164
+ useEdgeGestureGuard(deferBottomEdgeGestures && activelyPlaying);
165
+ return /* @__PURE__ */ jsx4(Fragment2, { children });
166
+ };
167
+ var KeepAwakeBridge = ({ children }) => {
168
+ const keepAwake = useSelector2((state) => state.keepAwake);
169
+ const dispatch = useDispatch2();
170
+ const onChange = useCallback2((settings) => dispatch(keepAwakeActions.initialize(settings)), [dispatch]);
171
+ return /* @__PURE__ */ jsx4(KeepAwakeProvider, { expoKeepAwake: ExpoKeepAwake, initialValue: keepAwake, onChange, children });
172
+ };
173
+ function Providers({ store, persistor, children }) {
174
+ return /* @__PURE__ */ jsx4(ReduxProvider, { store, children: /* @__PURE__ */ jsx4(PersistGate, { persistor, children: /* @__PURE__ */ jsx4(EdgeGuardBridge, { children: /* @__PURE__ */ jsx4(FeedbackBridge, { children: /* @__PURE__ */ jsx4(KeepAwakeBridge, { children }) }) }) }) });
175
+ }
176
+
177
+ // src/components/Theme.tsx
178
+ import MaskedView from "@react-native-masked-view/masked-view";
179
+ import { Provider as AutoPaperProvider, themeActions, useThemeBridgeProps } from "@rific/auto-paper";
180
+ import * as ExpoBlur from "expo-blur";
181
+ import * as SplashScreen from "expo-splash-screen";
182
+ import { useCallback as useCallback3 } from "react";
183
+ import Reanimated from "react-native-reanimated";
184
+ import { shallowEqual as shallowEqual2, useDispatch as useDispatch3, useSelector as useSelector3 } from "react-redux";
185
+ import { jsx as jsx5 } from "react/jsx-runtime";
186
+ SplashScreen.preventAutoHideAsync();
187
+ SplashScreen.setOptions({ fade: true, duration: 400 });
188
+ function Theme({ children }) {
189
+ const { fontFamily } = usePack();
190
+ const settings = useSelector3((state) => state.theme, shallowEqual2);
191
+ const dispatch = useDispatch3();
192
+ const onReady = useCallback3(() => markSplashReady("theme"), []);
193
+ const onChange = useCallback3((next) => dispatch(themeActions.initialize(next)), [dispatch]);
194
+ const bridgeProps = useThemeBridgeProps({ initialValue: settings, onChange, onReady });
195
+ return /* @__PURE__ */ jsx5(AutoPaperProvider, { ...bridgeProps, expoBlur: ExpoBlur, maskedView: MaskedView, statusBar: false, reanimated: Reanimated, fontFamily, children });
196
+ }
197
+
198
+ // src/components/UpdateDialog.tsx
199
+ import { useUpdateErrorToast } from "@rific/toaster";
200
+ import { useRotation as useRotation2 } from "@tastic/core";
201
+ import { UpdateDialog as SharedUpdateDialog } from "@tastic/hud";
202
+ import { jsx as jsx6 } from "react/jsx-runtime";
203
+ function UpdateDialog() {
204
+ const onError = useUpdateErrorToast();
205
+ const rotation = useRotation2();
206
+ return /* @__PURE__ */ jsx6(SharedUpdateDialog, { onError, rotation });
207
+ }
208
+
209
+ // src/hooks/useAppSettings.ts
210
+ import { useCallback as useCallback4 } from "react";
211
+ import { useDispatch as useDispatch4, useSelector as useSelector4 } from "react-redux";
212
+
213
+ // src/redux/appSettingsSlice.ts
214
+ import { createSettingsSlice } from "@rific/core";
215
+ var DEFAULT_APP_SETTINGS = {
216
+ lockOrientation: false,
217
+ deferBottomEdgeGestures: false
218
+ };
219
+ var { actions, reducer } = createSettingsSlice("appSettings", {
220
+ initialState: DEFAULT_APP_SETTINGS,
221
+ initializeMode: "merge",
222
+ fieldSetters: []
223
+ });
224
+ var appSettingsActions = actions;
225
+ var appSettingsSlice_default = reducer;
226
+
227
+ // src/hooks/useAppSettings.ts
228
+ function useAppSettings() {
229
+ const settings = useSelector4((state) => state.appSettings);
230
+ const dispatch = useDispatch4();
231
+ const setSettings = useCallback4(
232
+ (patch) => {
233
+ dispatch(appSettingsActions.initialize(patch));
234
+ },
235
+ [dispatch]
236
+ );
237
+ return { settings, setSettings };
238
+ }
239
+
240
+ // src/hooks/useLastActiveGame.tsx
241
+ import { createContext as createContext3, useContext as useContext3, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "react";
242
+ import { useDispatch as useDispatch5, useStore as useStore2 } from "react-redux";
243
+
244
+ // src/redux/lastActiveGameSlice.ts
245
+ import { createSettingsSlice as createSettingsSlice2 } from "@rific/core";
246
+ var { actions: actions2, reducer: reducer2 } = createSettingsSlice2("lastActiveGame", { initialState: { gameId: null } });
247
+ var lastActiveGameActions = actions2;
248
+ var lastActiveGameSlice_default = reducer2;
249
+
250
+ // src/hooks/useLastActiveGame.tsx
251
+ import { jsx as jsx7 } from "react/jsx-runtime";
252
+ var LastActiveGameContext = createContext3(null);
253
+ function LastActiveGameProvider({ children }) {
254
+ const store = useStore2();
255
+ const dispatch = useDispatch5();
256
+ const [initial] = useState2(() => store.getState().lastActiveGame?.gameId ?? null);
257
+ const pendingResume = useRef2(initial);
258
+ const persisted = useRef2(initial);
259
+ const value = useMemo2(
260
+ () => ({
261
+ consumeResumeGame: () => {
262
+ const game = pendingResume.current;
263
+ pendingResume.current = null;
264
+ return game;
265
+ },
266
+ setLastActiveGame: (gameId) => {
267
+ pendingResume.current = null;
268
+ if (persisted.current === gameId) return;
269
+ persisted.current = gameId;
270
+ dispatch(lastActiveGameActions.setGameId(gameId));
271
+ },
272
+ // Called from the play route's unmount cleanup (screens/GameRoute.tsx) - it fires on the play
273
+ // screen's own back, hardware back, or a browser back, but not on mere app backgrounding, where
274
+ // the screen stays mounted. That's exactly "explicitly back out."
275
+ clearLastActiveGame: () => {
276
+ pendingResume.current = null;
277
+ if (persisted.current === null) return;
278
+ persisted.current = null;
279
+ dispatch(lastActiveGameActions.setGameId(null));
280
+ }
281
+ }),
282
+ [dispatch]
283
+ );
284
+ return /* @__PURE__ */ jsx7(LastActiveGameContext.Provider, { value, children });
285
+ }
286
+ function useLastActiveGame() {
287
+ const ctx = useContext3(LastActiveGameContext);
288
+ if (!ctx) throw new Error("useLastActiveGame must be used within a LastActiveGameProvider");
289
+ return ctx;
290
+ }
291
+
292
+ // src/hooks/useProfiles.tsx
293
+ import { createProfileRecord, profilesActions, resolveInitialProfiles, useSharedProfilesSync } from "@tastic/profile";
294
+ import { createContext as createContext4, useCallback as useCallback5, useContext as useContext4, useEffect as useEffect2, useState as useState3 } from "react";
295
+ import { useDispatch as useDispatch6, useSelector as useSelector5 } from "react-redux";
296
+
297
+ // src/redux/profileSelectionSlice.ts
298
+ import { createProfileSelectionSlice } from "@tastic/profile";
299
+ var defaultProfileSelectionState = { 1: null, 2: null };
300
+ var { actions: actions3, reducer: reducer3 } = createProfileSelectionSlice("pack", defaultProfileSelectionState);
301
+ var profileSelectionActions = actions3;
302
+ var profileSelectionSlice_default = reducer3;
303
+
304
+ // src/hooks/useProfiles.tsx
305
+ import { jsx as jsx8 } from "react/jsx-runtime";
306
+ var SHARED_GROUP_ID = "group.com.infinitetoken.tastic";
307
+ var ProfilesContext = createContext4(null);
308
+ function ProfilesProvider({ children }) {
309
+ const profiles = useSelector5((state) => state.profiles);
310
+ const lastSelected = useSelector5((state) => state.profileSelection);
311
+ const dispatch = useDispatch6();
312
+ const [loaded, setLoaded] = useState3(false);
313
+ useEffect2(() => {
314
+ let cancelled = false;
315
+ resolveInitialProfiles(SHARED_GROUP_ID, profiles).then((resolved) => {
316
+ if (cancelled) return;
317
+ dispatch(profilesActions.setAll(resolved));
318
+ dispatch(profileSelectionActions.pruneToRoster(resolved.map((p) => p.id)));
319
+ setLoaded(true);
320
+ });
321
+ return () => {
322
+ cancelled = true;
323
+ };
324
+ }, []);
325
+ const { syncToShared } = useSharedProfilesSync({
326
+ groupId: SHARED_GROUP_ID,
327
+ onRemoteChange: useCallback5(
328
+ (remote) => {
329
+ dispatch(profilesActions.setAll(remote));
330
+ dispatch(profileSelectionActions.pruneToRoster(remote.map((p) => p.id)));
331
+ },
332
+ [dispatch]
333
+ )
334
+ });
335
+ const createProfile = useCallback5(
336
+ (input) => {
337
+ const record = createProfileRecord(input);
338
+ dispatch(profilesActions.add(record));
339
+ syncToShared([...profiles, record]);
340
+ return record;
341
+ },
342
+ [profiles, dispatch, syncToShared]
343
+ );
344
+ const updateProfile = useCallback5(
345
+ (id, patch) => {
346
+ dispatch(profilesActions.update({ id, patch }));
347
+ syncToShared(profiles.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p));
348
+ },
349
+ [profiles, dispatch, syncToShared]
350
+ );
351
+ const deleteProfile = useCallback5(
352
+ (id) => {
353
+ dispatch(profilesActions.remove(id));
354
+ syncToShared(profiles.filter((p) => p.id !== id));
355
+ dispatch(profileSelectionActions.clearProfile(id));
356
+ },
357
+ [profiles, dispatch, syncToShared]
358
+ );
359
+ const selectProfile = useCallback5(
360
+ (seat, profileId) => {
361
+ dispatch(profileSelectionActions.select({ profileId, seat }));
362
+ },
363
+ [dispatch]
364
+ );
365
+ return /* @__PURE__ */ jsx8(ProfilesContext.Provider, { value: { profiles, lastSelected, loaded, createProfile, updateProfile, deleteProfile, selectProfile }, children });
366
+ }
367
+ function useProfiles() {
368
+ const ctx = useContext4(ProfilesContext);
369
+ if (!ctx) throw new Error("useProfiles must be used within a ProfilesProvider");
370
+ return ctx;
371
+ }
372
+
373
+ // src/RootLayout.tsx
374
+ import { jsx as jsx9, jsxs as jsxs2 } from "react/jsx-runtime";
375
+ var PLAY_OPTIONS = { statusBarHidden: true };
376
+ function AppStack() {
377
+ const { dark } = useAutoPaperTheme();
378
+ const rootBackground = useThemedRootBackground(dark, false);
379
+ const statusBar = useRotationAwareStatusBar();
380
+ return /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar }, children: [
381
+ /* @__PURE__ */ jsx9(Stack.Screen, { name: "index" }),
382
+ /* @__PURE__ */ jsx9(Stack.Screen, { name: "play/[game]", options: PLAY_OPTIONS })
383
+ ] });
384
+ }
385
+ function OrientationBridge({ children }) {
386
+ const { settings, setSettings } = useAppSettings();
387
+ return /* @__PURE__ */ jsx9(OrientationProvider, { deviceMotion: DeviceMotion, freezeWhileLocked: true, lockInitialValue: { locked: settings.lockOrientation, rotation: settings.lockedRotation }, onLockChange: (next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation }), children });
388
+ }
389
+ function GatedApp({ children }) {
390
+ const { loaded } = useProfiles();
391
+ return /* @__PURE__ */ jsx9(SplashGate, { gate: "profiles", ready: loaded, children });
392
+ }
393
+ function HapticGatedToastProvider({ children }) {
394
+ const { settings } = useHapticSettings();
395
+ return /* @__PURE__ */ jsx9(ToastProvider, { haptics: settings.vibrate ? Haptics : void 0, paper: RNPaper2, children });
396
+ }
397
+ function RotatingToaster() {
398
+ const renderFrame = useCallback6(
399
+ (toasts) => /* @__PURE__ */ jsx9(RotatedFrame, { rotatedSafeArea: true, passThrough: true, children: toasts }),
400
+ []
401
+ );
402
+ return /* @__PURE__ */ jsx9(Toaster, { historyButton: null, clearButton: null, limit: 3, renderFrame });
403
+ }
404
+ function GameProviders({ games, children }) {
405
+ return games.reduceRight((inner, game) => /* @__PURE__ */ jsx9(game.Provider, { children: inner }, game.id), children);
406
+ }
407
+ function RootLayout({ pack, store, persistor }) {
408
+ return /* @__PURE__ */ jsx9(GestureHandlerRootView, { style: styles.root, children: /* @__PURE__ */ jsx9(SafeAreaProvider, { children: /* @__PURE__ */ jsx9(PackProvider, { pack, children: /* @__PURE__ */ jsx9(Fonts, { children: /* @__PURE__ */ jsx9(Providers, { store, persistor, children: /* @__PURE__ */ jsx9(Theme, { children: /* @__PURE__ */ jsx9(ProfilesProvider, { children: /* @__PURE__ */ jsx9(LastActiveGameProvider, { children: /* @__PURE__ */ jsx9(GameProviders, { games: pack.games, children: /* @__PURE__ */ jsx9(GatedApp, { children: /* @__PURE__ */ jsx9(OrientationBridge, { children: /* @__PURE__ */ jsxs2(HapticGatedToastProvider, { children: [
409
+ /* @__PURE__ */ jsx9(GuideHost, { children: /* @__PURE__ */ jsx9(AppStack, {}) }),
410
+ /* @__PURE__ */ jsx9(RotatingToaster, {}),
411
+ /* @__PURE__ */ jsx9(UpdateDialog, {})
412
+ ] }) }) }) }) }) }) }) }) }) }) }) });
413
+ }
414
+ var styles = StyleSheet.create({
415
+ root: { flex: 1 }
416
+ });
417
+
418
+ // src/screens/GameRoute.tsx
419
+ import { Redirect, useLocalSearchParams } from "expo-router";
420
+ import { useEffect as useEffect5 } from "react";
421
+
422
+ // src/screens/LoadoutScreen.tsx
423
+ import { defaultColors, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
424
+ import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from "@tastic/core";
425
+ import { CornerActionButtons, PlayerSetupPanel, PressAwayOverlay, ReadyButton as ReadyButton2, SharedActionBand, usePopoverHost } from "@tastic/hud";
426
+ import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from "@tastic/split-screen";
427
+ import { router as router2, useFocusEffect as useFocusEffect2 } from "expo-router";
428
+ import { useCallback as useCallback9, useEffect as useEffect4, useMemo as useMemo4, useRef as useRef4, useState as useState4 } from "react";
429
+ import { Platform as Platform2, StyleSheet as StyleSheet3, View as View2 } from "react-native";
430
+ import Animated from "react-native-reanimated";
431
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
432
+ import { useDispatch as useDispatch9 } from "react-redux";
433
+
434
+ // src/components/SeatTwoPanel.tsx
435
+ import { ReadyButton, useZoneClampedAlign } from "@tastic/hud";
436
+ import { getOptionPickerContentSize, OptionPicker } from "@tastic/profile";
437
+ import { useMemo as useMemo3 } from "react";
438
+ import { StyleSheet as StyleSheet2, View } from "react-native";
439
+ import { jsx as jsx10, jsxs as jsxs3 } from "react/jsx-runtime";
440
+ var CPU_PREFIX = "cpu:";
441
+ var PROFILE_PREFIX = "profile:";
442
+ var NOBODY = "nobody";
443
+ var GUEST = "guest";
444
+ var GUEST_ICON = "account-outline";
445
+ var NOBODY_ICON = "account-off-outline";
446
+ var CPU_ICON = "robot";
447
+ function getSeatTwoPopoverId(idPrefix) {
448
+ return `${idPrefix}-seat`;
449
+ }
450
+ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }) {
451
+ const isPerson = seatTwo === "person";
452
+ const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
453
+ const cpuOptions = cpuDifficulty?.options;
454
+ const options = useMemo3(() => {
455
+ const list = [];
456
+ 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 }]);
457
+ if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
458
+ if (choices.includes("person")) {
459
+ list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
460
+ 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 })));
461
+ }
462
+ return list;
463
+ }, [choices, cpuOptions, profiles]);
464
+ const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
465
+ const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
466
+ const handleChange = (next) => {
467
+ if (next.startsWith(CPU_PREFIX)) {
468
+ cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length));
469
+ onSeatTwoChange("cpu");
470
+ } else if (next === NOBODY) {
471
+ onSeatTwoChange("nobody");
472
+ } else {
473
+ onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null);
474
+ onSeatTwoChange("person");
475
+ }
476
+ };
477
+ const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0 };
478
+ const pickerId = getSeatTwoPopoverId(idPrefix);
479
+ const size = getOptionPickerContentSize(options.length, colorSection);
480
+ const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height);
481
+ const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
482
+ const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
483
+ return /* @__PURE__ */ jsxs3(View, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
484
+ /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color, dark, icon, colorSection, alignOverride: align }),
485
+ isPerson && secondPicker && /* @__PURE__ */ jsx10(View, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
486
+ isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
487
+ ] });
488
+ }
489
+ var styles2 = StyleSheet2.create({
490
+ panel: {
491
+ alignItems: "center",
492
+ gap: 12
493
+ },
494
+ // A popover escaping this panel needs the panel itself elevated to paint above a later sibling.
495
+ panelOpen: {
496
+ zIndex: 100
497
+ },
498
+ pickerRow: {
499
+ alignItems: "center",
500
+ flexDirection: "row",
501
+ gap: 12
502
+ },
503
+ // Lifts the controls picker's popover above the Ready button below it (each View is its own
504
+ // stacking context on the web).
505
+ pickerRowOpen: {
506
+ zIndex: 100
507
+ }
508
+ });
509
+
510
+ // src/components/SettingsDialog.tsx
511
+ import { useUpdateErrorToast as useUpdateErrorToast2 } from "@rific/toaster";
512
+ import { BaseSettingsDialog } from "@tastic/hud";
513
+ import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
514
+ function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }) {
515
+ const onUpdateError = useUpdateErrorToast2();
516
+ const { settings, setSettings } = useAppSettings();
517
+ const { otaVersion, SettingsSections } = usePack();
518
+ return /* @__PURE__ */ jsxs4(BaseSettingsDialog, { visible, onDismiss, rotation, version: otaVersion, lockOrientation: settings.lockOrientation, onLockOrientationChange: (value) => setSettings({ lockOrientation: value }), deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value) => setSettings({ deferBottomEdgeGestures: value }), onUpdateError, onShowHowToPlay, children: [
519
+ children,
520
+ SettingsSections && /* @__PURE__ */ jsx11(SettingsSections, {})
521
+ ] });
522
+ }
523
+
524
+ // src/hooks/useOpponent.ts
525
+ import { useCallback as useCallback7 } from "react";
526
+ import { useDispatch as useDispatch7, useSelector as useSelector6 } from "react-redux";
527
+
528
+ // src/redux/opponentSlice.ts
529
+ import { createSettingsSlice as createSettingsSlice3 } from "@rific/core";
530
+ var DEFAULT_OPPONENT = { seatTwo: "cpu" };
531
+ var { actions: actions4, reducer: reducer4 } = createSettingsSlice3("opponent", {
532
+ initialState: DEFAULT_OPPONENT
533
+ });
534
+ var opponentActions = actions4;
535
+ var opponentSlice_default = reducer4;
536
+
537
+ // src/hooks/useOpponent.ts
538
+ function useOpponent() {
539
+ const seatTwo = useSelector6((state) => state.opponent.seatTwo);
540
+ const dispatch = useDispatch7();
541
+ const setSeatTwo = useCallback7((next) => dispatch(opponentActions.setSeatTwo(next)), [dispatch]);
542
+ return { seatTwo, setSeatTwo };
543
+ }
544
+
545
+ // src/hooks/useSeatColors.ts
546
+ import { getThirdColor, useAutoPaperTheme as useAutoPaperTheme2, useThemeSettings } from "@rific/auto-paper";
547
+ import { avoidSeatColorClash, profileSeatColor } from "@tastic/profile";
548
+ import { useFocusEffect } from "expo-router";
549
+ import { useCallback as useCallback8, useEffect as useEffect3, useRef as useRef3 } from "react";
550
+ import { useDispatch as useDispatch8, useSelector as useSelector7 } from "react-redux";
551
+
552
+ // src/redux/seatColorsSlice.ts
553
+ import { createSeatColorsSlice } from "@tastic/profile";
554
+ function defaultSeatColorsState(seatColors) {
555
+ return { lastGuestColor: { 1: seatColors[1], 2: seatColors[2] }, lastCpuColor: seatColors[2], profileColors: {} };
556
+ }
557
+ function createSeatColorsReducer(seatColors) {
558
+ return createSeatColorsSlice("pack", defaultSeatColorsState(seatColors)).reducer;
559
+ }
560
+ var seatColorsActions = createSeatColorsSlice("pack", defaultSeatColorsState({ 1: "", 2: "" })).actions;
561
+
562
+ // src/hooks/useSeatColors.ts
563
+ function useSeatColors(p2IsHuman) {
564
+ const { colors: themeColors } = useAutoPaperTheme2();
565
+ const { set: setThemeColor } = useThemeSettings();
566
+ const p1Color = themeColors.primary;
567
+ const p2Color = themeColors.secondary;
568
+ const { profiles, lastSelected } = useProfiles();
569
+ const { seatColors } = usePack();
570
+ const seatDefaultColors = [seatColors[1], seatColors[2]];
571
+ const dispatch = useDispatch8();
572
+ const guestColors = useSelector7((state) => state.seatColors.lastGuestColor);
573
+ const cpuColor = useSelector7((state) => state.seatColors.lastCpuColor);
574
+ const profileColors = useSelector7((state) => state.seatColors.profileColors);
575
+ const p1Profile = profiles.find((p) => p.id === lastSelected[1]) ?? null;
576
+ const p2Profile = p2IsHuman ? profiles.find((p) => p.id === lastSelected[2]) ?? null : null;
577
+ const p1ProfileRef = useRef3(p1Profile);
578
+ const p2ProfileRef = useRef3(p2Profile);
579
+ const guestColorsRef = useRef3(guestColors);
580
+ const cpuColorRef = useRef3(cpuColor);
581
+ const profileColorsRef = useRef3(profileColors);
582
+ useEffect3(() => {
583
+ p1ProfileRef.current = p1Profile;
584
+ }, [p1Profile]);
585
+ useEffect3(() => {
586
+ p2ProfileRef.current = p2Profile;
587
+ }, [p2Profile]);
588
+ useEffect3(() => {
589
+ guestColorsRef.current = guestColors;
590
+ }, [guestColors]);
591
+ useEffect3(() => {
592
+ cpuColorRef.current = cpuColor;
593
+ }, [cpuColor]);
594
+ useEffect3(() => {
595
+ profileColorsRef.current = profileColors;
596
+ }, [profileColors]);
597
+ useEffect3(() => {
598
+ const nextP1 = p1Profile ? profileSeatColor(p1Profile, profileColors) : guestColors[1];
599
+ const nextP2 = avoidSeatColorClash(nextP1, p2Profile ? profileSeatColor(p2Profile, profileColors) : p2IsHuman ? guestColors[2] : cpuColor, seatDefaultColors);
600
+ if (nextP1.toLowerCase() === p1Color.toLowerCase() && nextP2.toLowerCase() === p2Color.toLowerCase()) return;
601
+ setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } });
602
+ }, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length]);
603
+ useFocusEffect(
604
+ useCallback8(() => {
605
+ const guest = guestColorsRef.current;
606
+ const cpu = cpuColorRef.current;
607
+ const p1Prof = p1ProfileRef.current;
608
+ const p2Prof = p2ProfileRef.current;
609
+ const picks = profileColorsRef.current;
610
+ const nextP1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1];
611
+ const nextP2 = avoidSeatColorClash(nextP1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, seatDefaultColors);
612
+ setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } });
613
+ }, [lastSelected[1], lastSelected[2], p2IsHuman, setThemeColor])
614
+ );
615
+ const persistSeatColor = useCallback8(
616
+ (seat, hex) => {
617
+ const profile = seat === 1 ? p1Profile : p2Profile;
618
+ if (profile) {
619
+ dispatch(seatColorsActions.setProfileColor({ profile, color: hex }));
620
+ return;
621
+ }
622
+ if (seat === 2 && !p2IsHuman) {
623
+ dispatch(seatColorsActions.setLastCpuColor(hex));
624
+ return;
625
+ }
626
+ dispatch(seatColorsActions.setLastGuestColor({ seat, color: hex }));
627
+ },
628
+ [p1Profile, p2Profile, p2IsHuman, dispatch]
629
+ );
630
+ const handleP1ColorChange = useCallback8(
631
+ (hex) => {
632
+ const swapped = hex.toLowerCase() === p2Color.toLowerCase();
633
+ const nextP2 = swapped ? p1Color : p2Color;
634
+ setThemeColor({ color: { primary: hex, secondary: nextP2, tertiary: getThirdColor(hex, nextP2) } });
635
+ persistSeatColor(1, hex);
636
+ if (swapped) persistSeatColor(2, nextP2);
637
+ },
638
+ [setThemeColor, p1Color, p2Color, persistSeatColor]
639
+ );
640
+ const handleP2ColorChange = useCallback8(
641
+ (hex) => {
642
+ const swapped = hex.toLowerCase() === p1Color.toLowerCase();
643
+ const nextP1 = swapped ? p2Color : p1Color;
644
+ setThemeColor({ color: { primary: nextP1, secondary: hex, tertiary: getThirdColor(nextP1, hex) } });
645
+ persistSeatColor(2, hex);
646
+ if (swapped) persistSeatColor(1, nextP1);
647
+ },
648
+ [setThemeColor, p1Color, p2Color, persistSeatColor]
649
+ );
650
+ return { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange };
651
+ }
652
+
653
+ // src/navigation.ts
654
+ import { configureNavigation, safeBack } from "@rific/core";
655
+ import { router } from "expo-router";
656
+ configureNavigation({ router });
657
+
658
+ // src/redux/liveplaySlice.ts
659
+ import { createSettingsSlice as createSettingsSlice4 } from "@rific/core";
660
+ var { actions: actions5, reducer: reducer5 } = createSettingsSlice4("liveplay", {
661
+ initialState: { activelyPlaying: false, activeRound: null }
662
+ });
663
+ var liveplayActions = actions5;
664
+ var liveplaySlice_default = reducer5;
665
+
666
+ // src/screens/LoadoutScreen.tsx
667
+ import { jsx as jsx12, jsxs as jsxs5 } from "react/jsx-runtime";
668
+ var PANEL_SWAP_FADE_MS = 180;
669
+ function LoadoutScreen({ game }) {
670
+ const pack = usePack();
671
+ const { seatTwo: savedSeatTwo, setSeatTwo } = useOpponent();
672
+ const seatTwo = seatTwoFor(savedSeatTwo, game.seatTwo);
673
+ const mode = roundModeFor(seatTwo);
674
+ const humanSeats = useMemo4(() => humanSeatsFor(mode), [mode]);
675
+ const p2IsHuman = mode === "twoPlayer";
676
+ const p2Exists = mode !== "solo";
677
+ const loadout = game.useLoadout(mode);
678
+ const { openFor } = useGameGuide();
679
+ const { profiles, lastSelected, selectProfile } = useProfiles();
680
+ const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState();
681
+ const fixedSeats = p2IsHuman && (!!game.twoPlayerSeats?.faceToFaceOnWeb || Platform2.OS !== "web");
682
+ const fixedUpsideDown = !!game.twoPlayerSeats?.followUpsideDown && orientationMode === "faceToFace" && upsideDown;
683
+ const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? "faceToFace" : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS);
684
+ const [settingsOpen, setSettingsOpen] = useState4(false);
685
+ const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown);
686
+ const insets = rotateInsets(useSafeAreaInsets(), rotation);
687
+ const { colors: themeColors, dark } = useAutoPaperTheme3();
688
+ const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman);
689
+ const dispatch = useDispatch9();
690
+ const handleP1ProfileSelect = useCallback9((profile) => selectProfile(1, profile?.id ?? null), [selectProfile]);
691
+ const handleP2ProfileSelect = useCallback9((profileId) => selectProfile(2, profileId), [selectProfile]);
692
+ const [ready, setReady] = useState4({ 1: false, 2: false });
693
+ useFocusEffect2(
694
+ useCallback9(() => {
695
+ setReady({ 1: false, 2: false });
696
+ }, [setReady])
697
+ );
698
+ const changeSeatTwo = useCallback9(
699
+ (next) => {
700
+ setReady({ 1: false, 2: false });
701
+ setSeatTwo(next);
702
+ },
703
+ [setSeatTwo, setReady]
704
+ );
705
+ const roundSettingsRef = useRef4(loadout.roundSettings);
706
+ useEffect4(() => {
707
+ roundSettingsRef.current = loadout.roundSettings;
708
+ }, [loadout.roundSettings]);
709
+ useEffect4(() => {
710
+ if (!humanSeats.every((s) => ready[s])) return;
711
+ dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }));
712
+ router2.push({ pathname: "/play/[game]", params: { game: game.id } });
713
+ }, [ready, humanSeats, game.id, mode, dispatch]);
714
+ const bg = dark ? "#000000" : "#FFFFFF";
715
+ const fg = dark ? "#FFFFFF" : "#000000";
716
+ const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
717
+ const isPortrait = panelLayout.orientationMode === "faceToFace";
718
+ const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length);
719
+ const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : panelLayout.p1OnRight ? styles3.p2ZoneLeft : styles3.p2ZoneRight : null;
720
+ const showReadyButton = p2IsHuman;
721
+ const controlsHost = usePopoverHost();
722
+ const p2PanelHost = usePopoverHost();
723
+ const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
724
+ const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith("p1-") || controlsHost.openId?.startsWith("p2-"));
725
+ const optionsRow = loadout.options({
726
+ host: controlsHost,
727
+ rotation,
728
+ accentColor: themeColors.tertiary,
729
+ mutedColor: fgMuted,
730
+ onAccentColor: themeColors.onTertiary,
731
+ dark,
732
+ // Left out (the row's own actions unrendered) whenever the shared band below carries them.
733
+ onRandomize: showMetaInSharedBand ? void 0 : loadout.randomize,
734
+ onReset: showMetaInSharedBand ? void 0 : loadout.reset
735
+ });
736
+ const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId);
737
+ const sharedControls = showMetaInSharedBand ? /* @__PURE__ */ jsx12(SharedActionBand, { onBack: safeBack, onSettings: () => setSettingsOpen(true), onRandomize: loadout.randomize, onReset: loadout.reset, fg, popoverOpen: optionsPopoverOpen, children: optionsRow }) : optionsRow;
738
+ const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation };
739
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color });
740
+ const p1Panel = /* @__PURE__ */ jsx12(PlayerSetupPanel, { idPrefix: "p1", host: controlsHost, color: p1Color, onColorChange: handleP1ColorChange, swatches: defaultColors, takenColor: p2Exists ? p2Color : void 0, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, isHuman: true, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), dark, showReadyButton, secondPicker: p1Control, secondPickerId: p1Control ? "p1-control" : void 0, profiles, selectedProfileId: lastSelected[1], takenProfileId: p2IsHuman ? lastSelected[2] : null, guestLabel: "P1", onProfileSelect: handleP1ProfileSelect, onManageProfiles: () => router2.push("/profiles") });
741
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : void 0;
742
+ const p2Panel = game.seatTwo && /* @__PURE__ */ jsx12(SeatTwoPanel, { idPrefix: "p2", host: p2Host, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, profiles, selectedProfileId: lastSelected[2], onProfileSelect: handleP2ProfileSelect, color: p2Color, onColorChange: handleP2ColorChange, swatches: defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, dark, secondPicker: p2Control, secondPickerId: p2Control ? "p2-control" : void 0, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), showReadyButton });
743
+ return /* @__PURE__ */ jsxs5(View2, { style: [styles3.container, { backgroundColor: bg }], children: [
744
+ /* @__PURE__ */ jsxs5(FakeLandscapeView, { orientationMode: panelLayout.orientationMode, p1OnRight: panelLayout.p1OnRight, upsideDown: panelLayout.upsideDown, style: styles3.rotatable, children: [
745
+ /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
746
+ p2ZoneStyle && /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null || p2Host.openId !== null, onPress: p2Host.close, style: p2ZoneStyle }),
747
+ !showMetaInSharedBand && /* @__PURE__ */ jsx12(CornerActionButtons, { onBack: safeBack, onSettings: () => setSettingsOpen(true), fg, insets }),
748
+ p2IsHuman ? /* @__PURE__ */ jsx12(
749
+ DualZoneLayout,
750
+ {
751
+ panelLayout,
752
+ panelFadeStyle,
753
+ p1: p1Panel,
754
+ p2: p2Panel,
755
+ shared: sharedControls,
756
+ p1Elevated: controlsHost.openId?.startsWith("p1-") ?? false,
757
+ p2Elevated: p2Host.openId !== null,
758
+ sharedElevated: optionsPopoverOpen
759
+ }
760
+ ) : /* @__PURE__ */ jsxs5(View2, { style: styles3.stackedZone, children: [
761
+ sharedControls,
762
+ /* @__PURE__ */ jsxs5(Animated.View, { style: [styles3.playersRow, anyPlayerPickerOpen && styles3.playersRowOpen, panelFadeStyle], children: [
763
+ !isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel,
764
+ !isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel
765
+ ] }),
766
+ /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Color, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
767
+ ] })
768
+ ] }),
769
+ /* @__PURE__ */ jsx12(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation, onShowHowToPlay: () => openFor(game.id) })
770
+ ] });
771
+ }
772
+ var styles3 = StyleSheet3.create({
773
+ container: {
774
+ flex: 1
775
+ },
776
+ p2ZoneLeft: {
777
+ bottom: 0,
778
+ left: 0,
779
+ position: "absolute",
780
+ top: 0,
781
+ width: "50%"
782
+ },
783
+ p2ZoneRight: {
784
+ bottom: 0,
785
+ position: "absolute",
786
+ right: 0,
787
+ top: 0,
788
+ width: "50%"
789
+ },
790
+ p2ZoneTop: {
791
+ height: "50%",
792
+ left: 0,
793
+ position: "absolute",
794
+ right: 0,
795
+ top: 0
796
+ },
797
+ // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
798
+ // at PlayerSetupPanel's own pickerRow gap (12).
799
+ playersRow: {
800
+ flexDirection: "row",
801
+ gap: 12
802
+ },
803
+ playersRowOpen: {
804
+ zIndex: 100
805
+ },
806
+ // See anyPlayerPickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
807
+ // pointerEvents:'none' keeps the sliver an open menu doesn't cover from being a hidden tap target.
808
+ readyButtonHidden: {
809
+ opacity: 0,
810
+ pointerEvents: "none"
811
+ },
812
+ // A real full-bleed box for FakeLandscapeView to size its absolutely-positioned children against.
813
+ rotatable: {
814
+ alignItems: "center",
815
+ flex: 1,
816
+ gap: 32,
817
+ justifyContent: "center",
818
+ paddingHorizontal: 16
819
+ },
820
+ stackedZone: {
821
+ alignItems: "center",
822
+ gap: 28
823
+ }
824
+ });
825
+
826
+ // src/screens/GameRoute.tsx
827
+ import { jsx as jsx13 } from "react/jsx-runtime";
828
+ function LoadoutRoute() {
829
+ const { game: id } = useLocalSearchParams();
830
+ const game = usePackGame(id);
831
+ if (!game) return /* @__PURE__ */ jsx13(Redirect, { href: "/" });
832
+ if (!hasLoadout(game)) return /* @__PURE__ */ jsx13(Redirect, { href: { pathname: "/play/[game]", params: { game: game.id } } });
833
+ return /* @__PURE__ */ jsx13(LoadoutScreen, { game }, game.id);
834
+ }
835
+ function hasLoadout(game) {
836
+ return !!game.useLoadout;
837
+ }
838
+ function PlayRoute() {
839
+ const { game: id } = useLocalSearchParams();
840
+ const game = usePackGame(id);
841
+ useResumePoint(game?.resumable ? game.id : null);
842
+ if (!game) return /* @__PURE__ */ jsx13(Redirect, { href: "/" });
843
+ const { PlayScreen } = game;
844
+ return /* @__PURE__ */ jsx13(PlayScreen, {}, game.id);
845
+ }
846
+ function useResumePoint(gameId) {
847
+ const { setLastActiveGame, clearLastActiveGame } = useLastActiveGame();
848
+ useEffect5(() => {
849
+ if (!gameId) return;
850
+ setLastActiveGame(gameId);
851
+ return clearLastActiveGame;
852
+ }, [gameId, setLastActiveGame, clearLastActiveGame]);
853
+ }
854
+
855
+ // src/screens/HomeScreen.tsx
856
+ import { defaultColors as defaultColors2, useAutoPaperTheme as useAutoPaperTheme4 } from "@rific/auto-paper";
857
+ import { IconButton, Pressable } from "@rific/feedback-press";
858
+ import { FakeLandscapeView as FakeLandscapeView2, useRotation as useRotation4 } from "@tastic/core";
859
+ import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost2, useWellColors, WELL_SHADOW } from "@tastic/hud";
860
+ import { ProfilePicker } from "@tastic/profile";
861
+ import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
862
+ import { useCallback as useCallback10, useEffect as useEffect6, useState as useState6 } from "react";
863
+ import { AppState, ScrollView, StyleSheet as StyleSheet4, View as View3 } from "react-native";
864
+ import { Icon, Text } from "react-native-paper";
865
+
866
+ // src/hooks/useFrozenWhileHidden.ts
867
+ import { useState as useState5 } from "react";
868
+ function useFrozenWhileHidden(value, hidden) {
869
+ const [prevHidden, setPrevHidden] = useState5(hidden);
870
+ const [prevValue, setPrevValue] = useState5(value);
871
+ const [frozen, setFrozen] = useState5(value);
872
+ if (hidden !== prevHidden) {
873
+ setPrevHidden(hidden);
874
+ if (hidden) setFrozen(prevValue);
875
+ }
876
+ if (value !== prevValue) {
877
+ setPrevValue(value);
878
+ }
879
+ return hidden ? frozen : value;
880
+ }
881
+
882
+ // src/screens/homeLayout.ts
883
+ import { clamp, rotateInsets as rotateInsets2, useRotatedWindowDimensions, useRotation as useRotation3 } from "@tastic/core";
884
+ import { useSafeAreaInsets as useSafeAreaInsets2 } from "react-native-safe-area-context";
885
+ var PREVIEW_ASPECT = 360 / 200;
886
+ var ROW_PREVIEW_WIDTH = 92;
887
+ var HOME_WIDE_EDGE_PADDING = 16;
888
+ var HOME_WIDE_PANE_GAP = 16;
889
+ var HOME_WIDE_RIGHT_GUTTER = 56;
890
+ var HOME_WIDE_MAX_CONTENT_WIDTH = 1100;
891
+ var HOME_LEFT_PANE_FRACTION = 0.38;
892
+ var HOME_LEFT_PANE_MIN = 260;
893
+ var HOME_LEFT_PANE_MAX = 340;
894
+ var HOME_GRID_GAP = 12;
895
+ var HOME_MIN_TILE_WIDTH = 140;
896
+ var HOME_MAX_COLUMNS = 3;
897
+ var HOME_TILE_PADDING = 10;
898
+ var HOME_TOP_BAND = 56;
899
+ var HOME_LEFT_PANE_PICKER_HEIGHT = 44;
900
+ var HOME_LEFT_PANE_PICKER_GAP = 12;
901
+ var HOME_LEFT_PANE_BOTTOM_PADDING = 16;
902
+ var HOME_HERO_SIDE_ROOM = 16;
903
+ function computeHomeLayout(windowWidth, windowHeight, insets) {
904
+ const isWide = windowWidth > windowHeight;
905
+ if (!isWide) {
906
+ return { isWide, windowWidth, windowHeight, insets, paddingLeft: 0, paddingRight: 0, leftPaneWidth: 0, rightPaneWidth: 0, columns: 1, tileWidth: 0, previewWidth: 0, heroFit: void 0 };
907
+ }
908
+ const usable = windowWidth - insets.left - insets.right;
909
+ const inner = Math.max(0, usable - HOME_WIDE_EDGE_PADDING - HOME_WIDE_RIGHT_GUTTER);
910
+ const content = Math.min(inner, HOME_WIDE_MAX_CONTENT_WIDTH);
911
+ const slack = (inner - content) / 2;
912
+ const leftPaneWidth = Math.min(clamp(Math.round(HOME_LEFT_PANE_FRACTION * content), HOME_LEFT_PANE_MIN, HOME_LEFT_PANE_MAX), Math.max(0, content - HOME_WIDE_PANE_GAP - HOME_MIN_TILE_WIDTH));
913
+ const rightPaneWidth = Math.max(0, content - HOME_WIDE_PANE_GAP - leftPaneWidth);
914
+ const columns = clamp(Math.floor((rightPaneWidth + HOME_GRID_GAP) / (HOME_MIN_TILE_WIDTH + HOME_GRID_GAP)), 1, HOME_MAX_COLUMNS);
915
+ const tileWidth = Math.max(0, Math.floor((rightPaneWidth - (columns - 1) * HOME_GRID_GAP) / columns));
916
+ const previewWidth = Math.max(0, tileWidth - 2 * HOME_TILE_PADDING);
917
+ const heroFit = {
918
+ width: Math.max(0, leftPaneWidth - HOME_HERO_SIDE_ROOM),
919
+ height: Math.max(0, windowHeight - insets.top - insets.bottom - HOME_TOP_BAND - HOME_LEFT_PANE_PICKER_GAP - HOME_LEFT_PANE_PICKER_HEIGHT - HOME_LEFT_PANE_BOTTOM_PADDING)
920
+ };
921
+ return {
922
+ isWide,
923
+ windowWidth,
924
+ windowHeight,
925
+ insets,
926
+ paddingLeft: insets.left + HOME_WIDE_EDGE_PADDING + slack,
927
+ paddingRight: insets.right + HOME_WIDE_RIGHT_GUTTER + slack,
928
+ leftPaneWidth,
929
+ rightPaneWidth,
930
+ columns,
931
+ tileWidth,
932
+ previewWidth,
933
+ heroFit
934
+ };
935
+ }
936
+ function useHomeLayout() {
937
+ const { width, height } = useRotatedWindowDimensions();
938
+ const rotation = useRotation3();
939
+ const insets = rotateInsets2(useSafeAreaInsets2(), rotation);
940
+ return computeHomeLayout(width, height, insets);
941
+ }
942
+
943
+ // src/screens/HomeScreen.tsx
944
+ import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs6 } from "react/jsx-runtime";
945
+ function HomeScreen() {
946
+ const pack = usePack();
947
+ const { consumeResumeGame } = useLastActiveGame();
948
+ const [resumeGame] = useState6(() => {
949
+ const id = consumeResumeGame();
950
+ return pack.games.find((g) => g.id === id && g.resumable) ?? null;
951
+ });
952
+ if (resumeGame) return /* @__PURE__ */ jsx14(Redirect2, { href: { pathname: "/play/[game]", params: { game: resumeGame.id } } });
953
+ return /* @__PURE__ */ jsx14(HomeMenu, {});
954
+ }
955
+ function HomeMenu() {
956
+ const pack = usePack();
957
+ const rotation = useRotation4();
958
+ const layout = useHomeLayout();
959
+ const { insets } = layout;
960
+ const { dark } = useAutoPaperTheme4();
961
+ const { openFirstTime, openFor } = useGameGuide();
962
+ const [settingsOpen, setSettingsOpen] = useState6(false);
963
+ const isFocused = useIsFocused();
964
+ const [appState, setAppState] = useState6(AppState.currentState);
965
+ useEffect6(() => {
966
+ const subscription = AppState.addEventListener("change", setAppState);
967
+ return () => subscription.remove();
968
+ }, []);
969
+ const heroActive = isFocused && appState === "active";
970
+ const { profiles, lastSelected, selectProfile } = useProfiles();
971
+ const { seatTwo } = useOpponent();
972
+ const { p1Color, p2Color, handleP1ColorChange } = useSeatColors(seatTwo === "person");
973
+ const pickerHost = usePopoverHost2();
974
+ const selectedProfile = profiles.find((p) => p.id === lastSelected[1]) ?? null;
975
+ const tiled = pack.games.some((game) => game.Preview);
976
+ const isWide = useFrozenWhileHidden(tiled && layout.isWide, !isFocused);
977
+ const [previewsRevealed, setPreviewsRevealed] = useState6(false);
978
+ useEffect6(() => {
979
+ if (!tiled) return;
980
+ const id = requestAnimationFrame(() => setPreviewsRevealed(true));
981
+ return () => cancelAnimationFrame(id);
982
+ }, [tiled]);
983
+ const closePicker = pickerHost.close;
984
+ useEffect6(() => {
985
+ closePicker();
986
+ }, [isWide, closePicker]);
987
+ const bg = dark ? "#000000" : "#FFFFFF";
988
+ const fg = dark ? "#FFFFFF" : "#000000";
989
+ const openGame = useCallback10((game) => openFirstTime(game.id, () => router3.push(game.useLoadout ? { pathname: "/loadout/[game]", params: { game: game.id } } : { pathname: "/play/[game]", params: { game: game.id } })), [openFirstTime]);
990
+ const picker = /* @__PURE__ */ jsx14(ProfilePicker, { idPrefix: "home-p1", host: pickerHost, profiles, selectedId: lastSelected[1], color: p1Color, dark, guestLabel: "P1", onSelect: (profile) => selectProfile(1, profile?.id ?? null), onManage: () => router3.push("/profiles"), colorSection: { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors2, caption: selectedProfile ? pack.colorCaption : void 0 } });
991
+ return /* @__PURE__ */ jsxs6(View3, { style: [styles4.container, { backgroundColor: bg }], children: [
992
+ /* @__PURE__ */ jsxs6(FakeLandscapeView2, { style: styles4.rotatable, children: [
993
+ /* @__PURE__ */ jsx14(PressAwayOverlay2, { active: pickerHost.openId !== null, onPress: pickerHost.close }),
994
+ isWide ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
995
+ /* @__PURE__ */ jsx14(ScrollView, { style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: /* @__PURE__ */ jsx14(View3, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameTile, { game, width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: () => openFor(game.id) }, game.id)) }) }),
996
+ /* @__PURE__ */ jsxs6(View3, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
997
+ /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
998
+ /* @__PURE__ */ jsx14(View3, { style: styles4.widePicker, children: picker })
999
+ ] })
1000
+ ] }) : /* @__PURE__ */ jsxs6(ScrollView, { style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1001
+ /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1002
+ /* @__PURE__ */ jsx14(View3, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1003
+ /* @__PURE__ */ jsx14(View3, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameRow, { game, dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: () => openFor(game.id) }, game.id)) })
1004
+ ] }),
1005
+ /* @__PURE__ */ jsx14(IconButton, { icon: "trophy", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, left: 8 + insets.left }], onPress: () => router3.push("/stats"), accessibilityLabel: "Stats & Achievements" }),
1006
+ /* @__PURE__ */ jsx14(IconButton, { icon: "cog", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, right: 8 + insets.right }], onPress: () => setSettingsOpen(true), accessibilityLabel: "Settings" })
1007
+ ] }),
1008
+ /* @__PURE__ */ jsx14(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation })
1009
+ ] });
1010
+ }
1011
+ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1012
+ const well = useWellColors();
1013
+ const GameIcon = game.icon;
1014
+ return (
1015
+ // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1016
+ // inside a button is invalid on the web.
1017
+ /* @__PURE__ */ jsxs6(View3, { children: [
1018
+ /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1019
+ game.Preview ? /* @__PURE__ */ jsx14(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ jsx14(View3, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: /* @__PURE__ */ jsx14(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
1020
+ /* @__PURE__ */ jsxs6(View3, { style: styles4.rowText, children: [
1021
+ /* @__PURE__ */ jsx14(Text, { variant: "titleLarge", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
1022
+ /* @__PURE__ */ jsx14(Text, { variant: "bodyMedium", style: { color: well.fgMuted }, numberOfLines: 2, children: game.blurb })
1023
+ ] })
1024
+ ] }),
1025
+ /* @__PURE__ */ jsx14(Pressable, { onPress: onRules, style: styles4.rulesButton, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: `${game.title} rules`, children: /* @__PURE__ */ jsx14(Icon, { source: "information-outline", size: 22, color: well.fgMuted }) })
1026
+ ] })
1027
+ );
1028
+ }
1029
+ function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1030
+ const well = useWellColors();
1031
+ return /* @__PURE__ */ jsxs6(View3, { style: { width }, children: [
1032
+ /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1033
+ /* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1034
+ /* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
1035
+ /* @__PURE__ */ jsx14(Text, { variant: "bodySmall", style: { color: well.fgMuted }, numberOfLines: 3, children: game.blurb })
1036
+ ] }),
1037
+ /* @__PURE__ */ jsx14(Pressable, { onPress: onRules, style: styles4.tileRulesButton, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: `${game.title} rules`, children: /* @__PURE__ */ jsx14(Icon, { source: "information-outline", size: 20, color: "#FFFFFF" }) })
1038
+ ] });
1039
+ }
1040
+ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }) {
1041
+ const height = Math.round(width / PREVIEW_ASPECT);
1042
+ const { Preview, icon: GameIcon } = game;
1043
+ const iconSize = Math.min(height, ROW_ICON_SIZE);
1044
+ return /* @__PURE__ */ jsx14(View3, { style: [styles4.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: Preview ? revealed && /* @__PURE__ */ jsx14(Preview, { width, height, p1Color, p2Color, dark }) : /* @__PURE__ */ jsx14(View3, { style: [styles4.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }], children: /* @__PURE__ */ jsx14(GameIcon, { size: iconSize, p1Color, p2Color, dark }) }) });
1045
+ }
1046
+ var ROW_ICON_SIZE = 56;
1047
+ var ICON_LIGHT_BG = "#FAFAFA";
1048
+ var ICON_DARK_BG = "#050505";
1049
+ var RULES_BUTTON_SPACE = 44;
1050
+ var styles4 = StyleSheet4.create({
1051
+ container: {
1052
+ flex: 1
1053
+ },
1054
+ content: {
1055
+ alignItems: "center",
1056
+ gap: 24
1057
+ },
1058
+ corner: {
1059
+ position: "absolute"
1060
+ },
1061
+ // The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
1062
+ grid: {
1063
+ flexDirection: "row",
1064
+ flexWrap: "wrap",
1065
+ gap: HOME_GRID_GAP
1066
+ },
1067
+ // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
1068
+ list: {
1069
+ gap: 14,
1070
+ maxWidth: 480,
1071
+ width: "100%"
1072
+ },
1073
+ picker: {
1074
+ alignItems: "center"
1075
+ },
1076
+ pickerOpen: {
1077
+ zIndex: 100
1078
+ },
1079
+ // The preview's own rounding, clipping whatever it draws to the box.
1080
+ preview: {
1081
+ alignItems: "center",
1082
+ borderRadius: 8,
1083
+ justifyContent: "center",
1084
+ overflow: "hidden"
1085
+ },
1086
+ previewIcon: {
1087
+ overflow: "hidden"
1088
+ },
1089
+ rotatable: {
1090
+ flex: 1
1091
+ },
1092
+ // A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
1093
+ // as Solitaire's game rows, the stats screen's sections and every dialog.
1094
+ row: {
1095
+ alignItems: "center",
1096
+ borderRadius: 12,
1097
+ boxShadow: WELL_SHADOW,
1098
+ flexDirection: "row",
1099
+ gap: 14,
1100
+ padding: 14,
1101
+ paddingRight: RULES_BUTTON_SPACE
1102
+ },
1103
+ rowIcon: {
1104
+ borderRadius: ROW_ICON_SIZE * 0.22,
1105
+ height: ROW_ICON_SIZE,
1106
+ overflow: "hidden",
1107
+ width: ROW_ICON_SIZE
1108
+ },
1109
+ rowText: {
1110
+ flex: 1,
1111
+ gap: 2
1112
+ },
1113
+ rowTitle: {
1114
+ fontWeight: "bold"
1115
+ },
1116
+ rulesButton: {
1117
+ bottom: 0,
1118
+ justifyContent: "center",
1119
+ position: "absolute",
1120
+ right: 14,
1121
+ top: 0
1122
+ },
1123
+ scroll: {
1124
+ flex: 1
1125
+ },
1126
+ // The same well as a row, turned on its side.
1127
+ tile: {
1128
+ borderRadius: 12,
1129
+ boxShadow: WELL_SHADOW,
1130
+ gap: 6,
1131
+ padding: HOME_TILE_PADDING
1132
+ },
1133
+ // A small translucent disc over the preview's top-right corner, so the white icon reads on whatever
1134
+ // the preview draws. hitSlop (on the Pressable) grows its touch area.
1135
+ tileRulesButton: {
1136
+ alignItems: "center",
1137
+ backgroundColor: "rgba(0, 0, 0, 0.35)",
1138
+ borderRadius: 14,
1139
+ height: 28,
1140
+ justifyContent: "center",
1141
+ position: "absolute",
1142
+ right: HOME_TILE_PADDING + 4,
1143
+ top: HOME_TILE_PADDING + 4,
1144
+ width: 28
1145
+ },
1146
+ // The two panes are absolutely positioned siblings of the press-away overlay, each placed by the
1147
+ // offsets homeLayout.ts computes.
1148
+ wideLeftPane: {
1149
+ alignItems: "center",
1150
+ bottom: 0,
1151
+ justifyContent: "center",
1152
+ pointerEvents: "box-none",
1153
+ position: "absolute",
1154
+ top: 0
1155
+ },
1156
+ widePicker: {
1157
+ marginTop: HOME_LEFT_PANE_PICKER_GAP
1158
+ },
1159
+ wideRightPane: {
1160
+ bottom: 0,
1161
+ position: "absolute",
1162
+ right: 0,
1163
+ top: 0
1164
+ }
1165
+ });
1166
+
1167
+ // src/screens/ProfilesRoute.tsx
1168
+ import { defaultColors as defaultColors3 } from "@rific/auto-paper";
1169
+ import { ProfilesScreen } from "@tastic/profile";
1170
+ import { jsx as jsx15 } from "react/jsx-runtime";
1171
+ function ProfilesRoute() {
1172
+ const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles();
1173
+ return /* @__PURE__ */ jsx15(ProfilesScreen, { profiles, defaultColor: defaultColors3[0].value, onCreate: (patch) => createProfile(patch), onSave: (id, patch) => updateProfile(id, patch), onDelete: deleteProfile, onBack: safeBack });
1174
+ }
1175
+
1176
+ // src/screens/StatsScreen.tsx
1177
+ import { useAutoPaperTheme as useAutoPaperTheme5 } from "@rific/auto-paper";
1178
+ import { FakeLandscapeView as FakeLandscapeView3, rotateInsets as rotateInsets3, useRotation as useRotation5 } from "@tastic/core";
1179
+ import { BaseStatsScreen, PressAwayOverlay as PressAwayOverlay3, usePopoverHost as usePopoverHost3 } from "@tastic/hud";
1180
+ import { OptionPicker as OptionPicker2 } from "@tastic/profile";
1181
+ import { useMemo as useMemo5, useState as useState7 } from "react";
1182
+ import { StyleSheet as StyleSheet5, View as View4 } from "react-native";
1183
+ import { SegmentedButtons, Text as Text2 } from "react-native-paper";
1184
+ import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
1185
+ import { useDispatch as useDispatch10 } from "react-redux";
1186
+ import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
1187
+ var TABS = [
1188
+ { value: "stats", label: "Stats", icon: "chart-box-outline" },
1189
+ { value: "achievements", label: "Achievements", icon: "trophy-outline" }
1190
+ ];
1191
+ var EVERYONE = "__everyone__";
1192
+ var ALL_GAMES = "__all__";
1193
+ var PLAYER_PICKER_ID = "stats-player";
1194
+ var GAME_PICKER_ID = "stats-game";
1195
+ function StatsScreen() {
1196
+ const pack = usePack();
1197
+ const { dark, colors } = useAutoPaperTheme5();
1198
+ const rotation = useRotation5();
1199
+ const insets = rotateInsets3(useSafeAreaInsets3(), rotation);
1200
+ const { profiles } = useProfiles();
1201
+ const dispatch = useDispatch10();
1202
+ const pickerHost = usePopoverHost3();
1203
+ const [tab, setTab] = useState7("stats");
1204
+ const [gameId, setGameId] = useState7(ALL_GAMES);
1205
+ const [selectedProfileId, setSelectedProfileId] = useState7(null);
1206
+ const profileId = selectedProfileId && profiles.some((p) => p.id === selectedProfileId) ? selectedProfileId : null;
1207
+ const games = gameId === ALL_GAMES ? pack.games : pack.games.filter((g) => g.id === gameId);
1208
+ const playerOptions = useMemo5(() => [{ value: EVERYONE, label: "Everyone", icon: "account-group" }, ...profiles.map((p) => ({ value: p.id, label: p.name, icon: "account", color: p.color }))], [profiles]);
1209
+ const gameOptions = useMemo5(() => [{ value: ALL_GAMES, label: "All Games", icon: "view-grid-outline" }, ...pack.games.map((g) => ({ value: g.id, label: g.title }))], [pack.games]);
1210
+ const resetScope = games.length === 1 ? games[0].title : "every game";
1211
+ const onReset = () => games.forEach((g) => dispatch(g.resetStats()));
1212
+ return /* @__PURE__ */ jsx16(FakeLandscapeView3, { style: styles5.rotatable, children: /* @__PURE__ */ jsxs7(BaseStatsScreen, { title: tab === "stats" ? "Stats" : "Achievements", onBack: safeBack, insets, onReset, resetLabel: games.length === 1 ? `Reset ${resetScope} Stats` : "Reset All Stats", resetConfirmTitle: "Reset Stats?", resetConfirmBody: `Erases every player's stats and achievements in ${resetScope}. This cannot be undone.`, rotation, children: [
1213
+ /* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
1214
+ /* @__PURE__ */ jsx16(SegmentedButtons, { value: tab, onValueChange: (value) => setTab(value), buttons: TABS, style: styles5.tabs }),
1215
+ /* @__PURE__ */ jsxs7(View4, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], children: [
1216
+ profiles.length > 0 && /* @__PURE__ */ jsx16(OptionPicker2, { id: PLAYER_PICKER_ID, host: pickerHost, options: playerOptions, value: profileId ?? EVERYONE, onChange: (value) => setSelectedProfileId(value === EVERYONE ? null : value), color: colors.primary, dark, icon: profileId ? "account" : "account-group" }),
1217
+ /* @__PURE__ */ jsx16(OptionPicker2, { id: GAME_PICKER_ID, host: pickerHost, options: gameOptions, value: gameId, onChange: setGameId, color: colors.primary, dark, icon: "view-grid-outline" })
1218
+ ] }),
1219
+ tab === "stats" && pack.StatsSections && /* @__PURE__ */ jsx16(pack.StatsSections, { profileId, gameId: gameId === ALL_GAMES ? null : gameId }),
1220
+ games.map((game) => /* @__PURE__ */ jsx16(GameStats, { game, tab, profileId, showTitle: games.length > 1 }, game.id))
1221
+ ] }) });
1222
+ }
1223
+ function GameStats({ game, tab, profileId, showTitle }) {
1224
+ const { colors } = useAutoPaperTheme5();
1225
+ const { StatsSections, AchievementList } = game;
1226
+ return /* @__PURE__ */ jsxs7(View4, { style: styles5.game, children: [
1227
+ showTitle && /* @__PURE__ */ jsx16(Text2, { variant: "titleLarge", style: [styles5.gameTitle, { color: colors.onBackground }], children: game.title }),
1228
+ tab === "stats" ? /* @__PURE__ */ jsx16(StatsSections, { profileId }) : /* @__PURE__ */ jsx16(AchievementList, { profileId })
1229
+ ] });
1230
+ }
1231
+ var styles5 = StyleSheet5.create({
1232
+ filters: {
1233
+ flexDirection: "row",
1234
+ flexWrap: "wrap",
1235
+ gap: 12,
1236
+ justifyContent: "center"
1237
+ },
1238
+ filtersOpen: {
1239
+ zIndex: 100
1240
+ },
1241
+ game: {
1242
+ gap: 16
1243
+ },
1244
+ gameTitle: {
1245
+ fontWeight: "bold",
1246
+ marginTop: 8
1247
+ },
1248
+ pressAway: {
1249
+ zIndex: 50
1250
+ },
1251
+ // A real full-bleed box for FakeLandscapeView to size its turned content against.
1252
+ rotatable: {
1253
+ flex: 1
1254
+ },
1255
+ // SegmentedButtons' own rounding.
1256
+ tabs: {
1257
+ borderRadius: 20
1258
+ }
1259
+ });
1260
+
1261
+ // src/components/imageGameIcon.tsx
1262
+ import { Image } from "react-native";
1263
+ import { jsx as jsx17 } from "react/jsx-runtime";
1264
+ function imageGameIcon(images) {
1265
+ return function ImageGameIcon({ size, dark }) {
1266
+ return /* @__PURE__ */ jsx17(Image, { source: dark ? images.dark : images.light, style: { height: size, width: size }, accessibilityIgnoresInvertColors: true });
1267
+ };
1268
+ }
1269
+
1270
+ // src/hooks/useActiveRound.ts
1271
+ import { useSelector as useSelector8 } from "react-redux";
1272
+ function useActiveRound(game, defaults) {
1273
+ const round = useSelector8((state) => state.liveplay.activeRound);
1274
+ const seated = !!usePackGame(game)?.seatTwo;
1275
+ if (!round || round.game !== game) return { mode: seated ? "vsCpu" : "solo", settings: defaults };
1276
+ return { mode: round.mode, settings: { ...defaults, ...round.settings } };
1277
+ }
1278
+
1279
+ // src/redux/createPackStore.ts
1280
+ import AsyncStorage from "@react-native-async-storage/async-storage";
1281
+ import { combineReducers, configureStore } from "@reduxjs/toolkit";
1282
+ import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from "redux-persist";
1283
+
1284
+ // src/redux/shellState.ts
1285
+ import { createThemeReducer, getThirdColor as getThirdColor2 } from "@rific/auto-paper";
1286
+ import { keepAwakeReducer } from "@rific/core";
1287
+ import { defaultSoundSettings, hapticReducer, soundReducer } from "@rific/feedback-press";
1288
+ import { profilesReducer } from "@tastic/profile";
1289
+ var initialSoundSettings = { ...defaultSoundSettings, enabled: !__DEV__ };
1290
+ var appSoundReducer = (state = initialSoundSettings, action) => soundReducer(state, action);
1291
+ function createShellReducers({ seatColors, appearance = "system" }) {
1292
+ return {
1293
+ // Explicit primary/secondary triad (rather than a single seed expanded via harmony) so the pack's
1294
+ // identity is pinned exactly instead of drifting with whatever hue math a harmony offset would
1295
+ // produce. Primary/secondary ARE player 1/player 2's own default colors (PackDefinition.seatColors),
1296
+ // so the theme and the default guest colors (seatColorsSlice.ts) can never drift apart. Tertiary is
1297
+ // derived, not picked, via getThirdColor: the hue maximally distant from both (see
1298
+ // @rific/auto-paper's README "Explicit triad" section). The appearance is the pack's ('system',
1299
+ // createThemeReducer's own default, when it sets none).
1300
+ theme: createThemeReducer({ appearance, color: { primary: seatColors[1], secondary: seatColors[2], tertiary: getThirdColor2(seatColors[1], seatColors[2]) } }),
1301
+ haptic: hapticReducer,
1302
+ sound: appSoundReducer,
1303
+ // @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its
1304
+ // REHYDRATE handling expects.
1305
+ keepAwake: keepAwakeReducer,
1306
+ // The shared App Group roster (see @tastic/profile's resolveInitialProfiles/
1307
+ // useSharedProfilesSync, wired in hooks/useProfiles.tsx) is layered on top of this as a separate
1308
+ // sync target - this slice persisting normally via redux-persist is what gives it local-fallback
1309
+ // behavior where the shared store isn't available, with no separate AsyncStorage path.
1310
+ profiles: profilesReducer,
1311
+ profileSelection: profileSelectionSlice_default,
1312
+ // The loadout's "remember this color for next time" memory (guest, CPU, and each profile's pick
1313
+ // in this pack) - separate from `theme` above, which always reflects whatever's live right now.
1314
+ seatColors: createSeatColorsReducer(seatColors),
1315
+ appSettings: appSettingsSlice_default,
1316
+ opponent: opponentSlice_default,
1317
+ // Transient per-round state, the one key blacklisted from persistence (see liveplaySlice.ts).
1318
+ liveplay: liveplaySlice_default
1319
+ };
1320
+ }
1321
+
1322
+ // src/redux/createPackStore.ts
1323
+ var hasError = (action) => typeof action === "object" && action !== null && "error" in action && Boolean(action.error);
1324
+ var errorMiddleware = () => (next) => (action) => {
1325
+ if (!hasError(action)) return next(action);
1326
+ return action;
1327
+ };
1328
+ function createPackStore(pack, { keyPrefix, migrate }) {
1329
+ const gameReducers = Object.assign({}, ...pack.games.map((game) => game.reducers));
1330
+ const resumeReducers = pack.games.some((game) => game.resumable) ? { lastActiveGame: lastActiveGameSlice_default } : {};
1331
+ const rootReducer = combineReducers({ ...createShellReducers(pack), ...resumeReducers, ...pack.reducers, ...gameReducers });
1332
+ const persistedReducer = persistReducer(
1333
+ {
1334
+ key: "root",
1335
+ keyPrefix,
1336
+ storage: AsyncStorage,
1337
+ // redux-persist defaults `timeout` to 5000ms: a failsafe setTimeout scheduled on every PERSIST
1338
+ // dispatch to force-resolve rehydrate if storage never responds. It's never cleared once
1339
+ // rehydrate resolves normally (only guarded by an internal `_sealed` flag), so it sits as a
1340
+ // pending timer for up to 5s after every store creation - under Jest that's a real open handle
1341
+ // ("A worker process has failed to exit gracefully"). Disabling it (falsy timeout skips the
1342
+ // setTimeout call entirely) is redux-persist's own documented way to opt out; AsyncStorage reads
1343
+ // failing to ever resolve at all isn't a failure mode worth a 5s failsafe for.
1344
+ timeout: 0,
1345
+ // liveplay is per-round state that must never survive a relaunch (see liveplaySlice.ts).
1346
+ blacklist: ["liveplay"],
1347
+ migrate
1348
+ },
1349
+ rootReducer
1350
+ );
1351
+ const store = configureStore({
1352
+ middleware: (getDefaultMiddleware) => {
1353
+ const defaultMiddleware = getDefaultMiddleware({
1354
+ immutableCheck: false,
1355
+ serializableCheck: {
1356
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
1357
+ }
1358
+ });
1359
+ return defaultMiddleware.concat(errorMiddleware);
1360
+ },
1361
+ reducer: persistedReducer
1362
+ });
1363
+ const persistor = persistStore(store);
1364
+ return { store, persistor };
1365
+ }
1366
+
1367
+ // src/redux/gameGuideSlice.ts
1368
+ var REHYDRATE2 = "persist/REHYDRATE";
1369
+ function createGameGuideSlice(mountKey) {
1370
+ const MARK_SEEN = `${mountKey}/markSeen`;
1371
+ const initialState = { versionSeen: 0 };
1372
+ function markSeen(version) {
1373
+ return { type: MARK_SEEN, payload: version };
1374
+ }
1375
+ function reducer6(state = initialState, action) {
1376
+ if (action.type === REHYDRATE2) {
1377
+ const persisted = action.payload?.[mountKey];
1378
+ return typeof persisted?.versionSeen === "number" ? { versionSeen: persisted.versionSeen } : state;
1379
+ }
1380
+ if (action.type === MARK_SEEN && typeof action.payload === "number") {
1381
+ return { versionSeen: Math.max(state.versionSeen, action.payload) };
1382
+ }
1383
+ return state;
1384
+ }
1385
+ function selectVersionSeen(rootState) {
1386
+ return rootState[mountKey]?.versionSeen ?? 0;
1387
+ }
1388
+ return { actions: { markSeen }, reducer: reducer6, selectVersionSeen };
1389
+ }
1390
+
1391
+ // src/redux/onProfileRemoved.ts
1392
+ import { removeProfileUnlocks } from "@tastic/achievements";
1393
+ import { profilesActions as profilesActions2 } from "@tastic/profile";
1394
+ function onProfileRemoved(reducer6, forget) {
1395
+ return (state, action) => {
1396
+ const next = reducer6(state, action);
1397
+ return profilesActions2.remove.match(action) ? forget(next, action.payload) : next;
1398
+ };
1399
+ }
1400
+ function forgetProfileAchievements(dropStats) {
1401
+ return (state, profileId) => {
1402
+ const stats = dropStats(state.stats, profileId);
1403
+ const unlocked = removeProfileUnlocks(state.unlocked, profileId);
1404
+ return stats === state.stats && unlocked === state.unlocked ? state : { ...state, stats, unlocked };
1405
+ };
1406
+ }
1407
+
1408
+ // src/gameStats.tsx
1409
+ import { createAchievementsSlice, mapUnlocksBySeat, useAchievements } from "@tastic/achievements";
1410
+ import { createContext as createContext5, useCallback as useCallback11, useContext as useContext5, useMemo as useMemo6 } from "react";
1411
+ import { jsx as jsx18 } from "react/jsx-runtime";
1412
+ var SEATS = [1, 2];
1413
+ function createGameStats({ key, defaultStats, isValidStats, catalog, getProfileStatsView }) {
1414
+ const { actions: actions6, reducer: sliceReducer } = createAchievementsSlice({
1415
+ key,
1416
+ defaultStats,
1417
+ isValidStats,
1418
+ // Backfills `profiles` onto stats saved without it.
1419
+ migrateStats: (stored) => ({ ...stored, profiles: stored.profiles ?? {} })
1420
+ });
1421
+ const dropProfileStats = (stats, profileId) => {
1422
+ if (!(profileId in stats.profiles)) return stats;
1423
+ const { [profileId]: _removed, ...rest } = stats.profiles;
1424
+ return { ...stats, profiles: rest };
1425
+ };
1426
+ const reducer6 = onProfileRemoved(sliceReducer, forgetProfileAchievements(dropProfileStats));
1427
+ const profileViews = (stats) => Object.fromEntries(Object.entries(stats.profiles).map(([profileId, bucket]) => [profileId, getProfileStatsView(bucket)]));
1428
+ const Context = createContext5(null);
1429
+ function Provider({ children }) {
1430
+ const { stats, unlockedAchievements, recordOutcome, resetAll } = useAchievements({
1431
+ catalog,
1432
+ select: (state) => state[key],
1433
+ actions: actions6,
1434
+ profileViews
1435
+ });
1436
+ const record = useCallback11(
1437
+ (update, profileIds) => {
1438
+ const result = recordOutcome(update);
1439
+ return { device: result.device, profiles: mapUnlocksBySeat(SEATS, profileIds, result.profiles) };
1440
+ },
1441
+ [recordOutcome]
1442
+ );
1443
+ const value = useMemo6(() => ({ stats, unlockedAchievements, record, resetAll }), [stats, unlockedAchievements, record, resetAll]);
1444
+ return /* @__PURE__ */ jsx18(Context.Provider, { value, children });
1445
+ }
1446
+ function useStats() {
1447
+ const ctx = useContext5(Context);
1448
+ if (!ctx) throw new Error(`The ${key} stats are read within their game's Provider`);
1449
+ return ctx;
1450
+ }
1451
+ return { reducer: reducer6, actions: actions6, Provider, useStats };
1452
+ }
1453
+
1454
+ // src/playerSettings.ts
1455
+ import { useCallback as useCallback12, useMemo as useMemo7 } from "react";
1456
+ import { useDispatch as useDispatch11, useSelector as useSelector9 } from "react-redux";
1457
+ var SEATS2 = [1, 2];
1458
+ function seatPlayerKey(selection, seat) {
1459
+ return selection[seat] ?? `guest:${seat}`;
1460
+ }
1461
+ function createPlayerSettings({ id, defaults, distinctControls }) {
1462
+ const settingsKey = `${id}Settings`;
1463
+ const controlsKey = `${id}Controls`;
1464
+ const PATCH = `${settingsKey}/patch`;
1465
+ const SET_CONTROL = `${controlsKey}/set`;
1466
+ const patch = (player, changes) => ({ type: PATCH, payload: { player, changes } });
1467
+ const setControl = (player, control) => ({ type: SET_CONTROL, payload: { player, control } });
1468
+ function forget(state, profileId) {
1469
+ if (!(profileId in state)) return state;
1470
+ const { [profileId]: _removed, ...rest } = state;
1471
+ return rest;
1472
+ }
1473
+ const settingsReducer = onProfileRemoved((state = {}, action) => {
1474
+ if (action.type !== PATCH) return state;
1475
+ const { player, changes } = action.payload;
1476
+ return { ...state, [player]: { ...state[player], ...changes } };
1477
+ }, forget);
1478
+ const controlsReducer = onProfileRemoved((state = {}, action) => {
1479
+ if (action.type !== SET_CONTROL) return state;
1480
+ const { player, control } = action.payload;
1481
+ return state[player] === control ? state : { ...state, [player]: control };
1482
+ }, forget);
1483
+ function compose(saved, controls, selection, seatTwoIsPerson) {
1484
+ const control1 = controls?.[seatPlayerKey(selection, 1)] ?? defaults.controlScheme[1];
1485
+ let control2 = (seatTwoIsPerson ? controls?.[seatPlayerKey(selection, 2)] : void 0) ?? defaults.controlScheme[2];
1486
+ if (distinctControls && control2 === control1) control2 = distinctControls.find((c) => c !== control1) ?? control2;
1487
+ return { ...defaults, ...saved?.[seatPlayerKey(selection, 1)], controlScheme: { 1: control1, 2: control2 } };
1488
+ }
1489
+ function select(state) {
1490
+ const root = state;
1491
+ return compose(root[settingsKey], root[controlsKey], state.profileSelection, state.opponent.seatTwo === "person");
1492
+ }
1493
+ function useSettings() {
1494
+ const selection = useSelector9((state) => state.profileSelection);
1495
+ const seatTwoIsPerson = useSelector9((state) => state.opponent.seatTwo === "person");
1496
+ const saved = useSelector9((state) => state[settingsKey]);
1497
+ const controls = useSelector9((state) => state[controlsKey]);
1498
+ const dispatch = useDispatch11();
1499
+ const settings = useMemo7(() => compose(saved, controls, selection, seatTwoIsPerson), [saved, controls, selection, seatTwoIsPerson]);
1500
+ const setSettings = useCallback12(
1501
+ (next) => {
1502
+ const { controlScheme, ...changes } = next;
1503
+ if (Object.keys(changes).length > 0) dispatch(patch(seatPlayerKey(selection, 1), changes));
1504
+ if (!controlScheme) return;
1505
+ for (const seat of SEATS2) {
1506
+ if (seat === 2 && !seatTwoIsPerson) continue;
1507
+ if (controlScheme[seat] !== settings.controlScheme[seat]) dispatch(setControl(seatPlayerKey(selection, seat), controlScheme[seat]));
1508
+ }
1509
+ },
1510
+ [dispatch, selection, seatTwoIsPerson, settings.controlScheme]
1511
+ );
1512
+ return { settings, setSettings };
1513
+ }
1514
+ return { reducers: { [settingsKey]: settingsReducer, [controlsKey]: controlsReducer }, settingsReducer, controlsReducer, actions: { patch, setControl }, compose, select, useSettings };
1515
+ }
1516
+ export {
1517
+ DEFAULT_APP_SETTINGS,
1518
+ DEFAULT_OPPONENT,
1519
+ HomeScreen,
1520
+ LoadoutRoute,
1521
+ PREVIEW_ASPECT,
1522
+ PackProvider,
1523
+ PlayRoute,
1524
+ ProfilesProvider,
1525
+ ProfilesRoute,
1526
+ RootLayout,
1527
+ SettingsDialog,
1528
+ StatsScreen,
1529
+ appSettingsActions,
1530
+ appSettingsSlice_default as appSettingsReducer,
1531
+ createGameGuideSlice,
1532
+ createGameStats,
1533
+ createPackStore,
1534
+ createPlayerSettings,
1535
+ createSeatColorsReducer,
1536
+ createShellReducers,
1537
+ defaultProfileSelectionState,
1538
+ defaultSeatColorsState,
1539
+ forgetProfileAchievements,
1540
+ humanSeatsFor,
1541
+ imageGameIcon,
1542
+ liveplayActions,
1543
+ liveplaySlice_default as liveplayReducer,
1544
+ onProfileRemoved,
1545
+ opponentActions,
1546
+ opponentSlice_default as opponentReducer,
1547
+ profileSelectionActions,
1548
+ profileSelectionSlice_default as profileSelectionReducer,
1549
+ roundModeFor,
1550
+ safeBack,
1551
+ seatColorsActions,
1552
+ seatPlayerKey,
1553
+ seatTwoFor,
1554
+ useActiveRound,
1555
+ useAppSettings,
1556
+ useGameGuide,
1557
+ useOpponent,
1558
+ usePack,
1559
+ usePackGame,
1560
+ useProfiles,
1561
+ useSeatColors
1562
+ };