feat: enhance game store with player game statistics and auto-save functionality

- Added PlayerGameResult and PlayerGameStats types to track player results.
- Introduced setupDraft type for game setup configuration.
- Implemented playerGameStats in GameStoreState to maintain individual player results.
- Added autoResetIfInactive method to reset game state after inactivity.
- Created useAppStateListener hook for managing app state changes and auto-saving game state.
- Developed streakService to calculate and format player win/loss streaks.
- Updated game setup logic to preserve player stats and configurations.
- Enhanced tests to cover new player theme and game state features.
This commit is contained in:
nessar committed 2026-04-12 16:12:59 +02:00
1 parent 534e495165
commit 23be3d245e
12 files changed
+808 -214

No files matched your search

+2
View File
@@ -6,10 +6,12 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppErrorBoundary } from './src/components/AppErrorBoundary';
import { AppNavigator } from './src/navigation/AppNavigator';
import { useInAppUpdates } from './src/hooks/useInAppUpdates';
import { useAppStateListener } from './src/hooks/useAppStateListener';
import { ui } from './src/styles/ui';
export default function App() {
useInAppUpdates();
useAppStateListener();
return (
<GestureHandlerRootView style={styles.root}>
+10 -7
View File
@@ -10,12 +10,13 @@
"@react-navigation/native-stack": "^7.14.10",
"expo": "~55.0.14",
"expo-asset": "~55.0.14",
"expo-dev-client": "~55.0.26",
"expo-dev-client": "~55.0.27",
"expo-file-system": "~55.0.16",
"expo-sensors": "~55.0.13",
"expo-status-bar": "~55.0.5",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.30.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
@@ -223,7 +224,7 @@
"@babel/preset-react": ["@babel/preset-react@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ=="],
"@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -927,11 +928,11 @@
"expo-constants": ["expo-constants@55.0.13", "", { "dependencies": { "@expo/config": "~55.0.14", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-imSsHm94KWsJbBLvjsUNgubcPQ3H6dXaAm0IZj2Y6+XEdJmjWo2JreriYmeSu/azmpiYUd3Y7K+/Hq9WXQ2Elg=="],
"expo-dev-client": ["expo-dev-client@55.0.26", "", { "dependencies": { "expo-dev-launcher": "55.0.27", "expo-dev-menu": "55.0.22", "expo-dev-menu-interface": "55.0.2", "expo-manifests": "~55.0.15", "expo-updates-interface": "~55.1.5" }, "peerDependencies": { "expo": "*" } }, "sha512-0XQFT42RjnmWQKgsWZOpE2MY6diX4ohrhXHkSf4ArFuJmBJkLXe2eQ0Sa3cDh3JssxuriaATNHdlogUVvJqYwA=="],
"expo-dev-client": ["expo-dev-client@55.0.27", "", { "dependencies": { "expo-dev-launcher": "55.0.28", "expo-dev-menu": "55.0.23", "expo-dev-menu-interface": "55.0.2", "expo-manifests": "~55.0.15", "expo-updates-interface": "~55.1.5" }, "peerDependencies": { "expo": "*" } }, "sha512-Ala2ER6v1NPFySdnuQN/GpQZI9r7E9eSMvzD32vv8A4FJkPfzjvj6U5uH4IytQQLVTa837fknXRvOMwRb9ZYQQ=="],
"expo-dev-launcher": ["expo-dev-launcher@55.0.27", "", { "dependencies": { "@expo/schema-utils": "^55.0.3", "expo-dev-menu": "55.0.22", "expo-manifests": "~55.0.15" }, "peerDependencies": { "expo": "*" } }, "sha512-V5UBfkSRFswNau4dnaHUDGt0HtRka+W+3eR1TnIqOYfDtAyT+DGxp91DY2E22ao6t1KhwhYOmQhcuvsWzgaYbg=="],
"expo-dev-launcher": ["expo-dev-launcher@55.0.28", "", { "dependencies": { "@expo/schema-utils": "^55.0.3", "expo-dev-menu": "55.0.23", "expo-manifests": "~55.0.15" }, "peerDependencies": { "expo": "*" } }, "sha512-jwCD7YeGqZMWrS4KODb51kPBDta8bDrPjhYCnodglcQ0jhPVsAnVSbtsQ2/TaaYUUtv+ipm1ufhNTFtcwya9kA=="],
"expo-dev-menu": ["expo-dev-menu@55.0.22", "", { "dependencies": { "expo-dev-menu-interface": "55.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-18kTcBshIqg8G9TxlldNZG0sky+wy6ERzGpVF6EqR5X1O0vKWH5cm2xEJdwhIXbWd8BldjxVFddniNd8P3uKXA=="],
"expo-dev-menu": ["expo-dev-menu@55.0.23", "", { "dependencies": { "expo-dev-menu-interface": "55.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-m6B2EwkoX9hwzP50EZPX8vc/szGfvNFsUTw3ExTWnxiJ9/IM0WfCFYjt8siFjMHcyblIjBt/XLN6mw6q8m2AEg=="],
"expo-dev-menu-interface": ["expo-dev-menu-interface@55.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-DomUNvGzY/xliwnMdbAYY780sCv19N7zIbifc0ClcoCzJZpNSCkvJ2qGIFRPyM/7DmqmlHGCKi8di7kYYLKNEg=="],
@@ -1535,6 +1536,8 @@
"react-native-device-info": ["react-native-device-info@10.3.0", "", { "peerDependencies": { "react-native": "*" } }, "sha512-/ziZN1sA1REbJTv5mQZ4tXggcTvSbct+u5kCaze8BmN//lbxcTvWsU6NQd4IihLt89VkbX+14IGc9sVApSxd/w=="],
"react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="],
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
@@ -1957,8 +1960,6 @@
"babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"babel-preset-expo/@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2075,6 +2076,8 @@
"react-native-siren/react-native-device-info": ["react-native-device-info@8.7.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-cVMZztFa2Qn6qpQa601W61CtUwZQ1KXfqCOeltejAWEXmgIWivC692WGSdtGudj4upSi1UgMSaGcvKjfcpdGjg=="],
"react-native-worklets/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
+2 -1
View File
@@ -20,12 +20,13 @@
"@react-navigation/native-stack": "^7.14.10",
"expo": "~55.0.14",
"expo-asset": "~55.0.14",
"expo-dev-client": "~55.0.26",
"expo-dev-client": "~55.0.27",
"expo-file-system": "~55.0.16",
"expo-sensors": "~55.0.13",
"expo-status-bar": "~55.0.5",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.30.1",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
+19
View File
@@ -13,6 +13,7 @@ type PlayerAvatarProps = {
roundScore?: number | null;
rollsUsed?: number | null;
badgesText?: string | null;
streakBadge?: string | null;
cardStyle?: StyleProp<ViewStyle>;
};
@@ -28,6 +29,7 @@ export function PlayerAvatar({
roundScore,
rollsUsed,
badgesText,
streakBadge,
cardStyle,
}: PlayerAvatarProps) {
const initial = name.trim().charAt(0).toUpperCase() || '?';
@@ -45,6 +47,11 @@ export function PlayerAvatar({
<Text style={[styles.name, compact && styles.nameCompact]} numberOfLines={1}>
{name}
</Text>
{streakBadge ? (
<Text style={[styles.streakBadge, compact && styles.streakBadgeCompact]}>
{streakBadge}
</Text>
) : null}
<Text style={[styles.meta, compact && styles.metaCompact]}>🍻 {drinks} gorgées</Text>
<Text style={[styles.meta, compact && styles.metaCompact]}>🎯 {lastScore ?? '--'}</Text>
{roundMeta && <Text style={[styles.meta, compact && styles.metaCompact]}>{roundMeta}</Text>}
@@ -146,6 +153,18 @@ const styles = StyleSheet.create({
marginTop: ui.space.micro,
lineHeight: ui.lineHeight.xs,
},
streakBadge: {
...presets.text.caption,
color: ui.color.highlight,
fontWeight: textWeight.bold,
fontSize: ui.fontSize.lg,
marginBottom: ui.space.xxs,
textAlign: 'center',
},
streakBadgeCompact: {
fontSize: ui.fontSize.md,
marginBottom: ui.space.micro,
},
resetButton: {
...presets.button.compact,
marginTop: ui.space.xs,
+107
View File
@@ -0,0 +1,107 @@
import { AppState, type AppStateStatus } from 'react-native';
import { useEffect, useRef } from 'react';
import * as FileSystem from 'expo-file-system/legacy';
import { useGameStore } from '../store';
const PERSIST_FILENAME = 'game-store.json';
const AUTO_SAVE_INTERVAL = 60000; // Auto-save every 60 seconds while active
/**
* Hook that forces game state persistence when the app enters background
* and periodically while active. This ensures that if the app is killed,
* the game state can be restored when the user returns to the app.
*/
export function useAppStateListener() {
const appStateRef = useRef<AppStateStatus>('active');
const autoSaveIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
useGameStore.getState().autoResetIfInactive();
const subscription = AppState.addEventListener('change', handleAppStateChange);
// Start periodic auto-save when component mounts
startAutoSave();
return () => {
subscription.remove();
stopAutoSave();
};
}, []);
const handleAppStateChange = async (nextAppState: AppStateStatus) => {
if (appStateRef.current === 'active' && nextAppState.match(/inactive|background/)) {
// App is going to background - force immediate save and stop auto-save
stopAutoSave();
await forceSaveGameState();
} else if (nextAppState === 'active' && appStateRef.current.match(/inactive|background/)) {
// App is coming back to foreground - resume auto-save
useGameStore.getState().autoResetIfInactive();
startAutoSave();
}
appStateRef.current = nextAppState;
};
const startAutoSave = () => {
autoSaveIntervalRef.current = setInterval(() => {
forceSaveGameState();
}, AUTO_SAVE_INTERVAL);
};
const stopAutoSave = () => {
if (autoSaveIntervalRef.current) {
clearInterval(autoSaveIntervalRef.current);
autoSaveIntervalRef.current = null;
}
};
const forceSaveGameState = async () => {
try {
const storePath = `${FileSystem.documentDirectory}${PERSIST_FILENAME}`;
// Get the current state
const state = useGameStore.getState();
// Build the full persisted state based on the partialize function used in the store
const persistedState = {
state: {
// Configuration and profiles
allTimeLeaderboard: state.allTimeLeaderboard,
savedProfiles: state.savedProfiles,
persistedMagicNumberEnabled: state.persistedMagicNumberEnabled,
persistedQualitySetting: state.persistedQualitySetting,
persistentPlayerThemes: state.persistentPlayerThemes,
setupDraft: state.setupDraft,
// Current game state
phase: state.phase,
players: state.players,
settings: state.settings,
round: state.round,
pot: state.pot,
currentPlayerIndex: state.currentPlayerIndex,
turns: state.turns,
roundSummary: state.roundSummary,
roundSummaryApplied: state.roundSummaryApplied,
tieBreakPlayerIds: state.tieBreakPlayerIds,
tieBreakTurns: state.tieBreakTurns,
tieBreakRound: state.tieBreakRound,
tieBreakBaseTurns: state.tieBreakBaseTurns,
tieBreakEndedBy31: state.tieBreakEndedBy31,
// Player statistics
playerRollStats: state.playerRollStats,
allRolls: state.allRolls,
rareEvents: state.rareEvents,
playerGameStats: state.playerGameStats,
lastGameActivityAt: state.lastGameActivityAt,
},
version: 0,
};
await FileSystem.writeAsStringAsync(storePath, JSON.stringify(persistedState));
} catch (error) {
console.warn('Failed to force-save game state:', error);
}
};
}
+9 -6
View File
@@ -28,6 +28,7 @@ import { RulesModal } from '../components/RulesModal';
import { useProvisionalLoser } from '../hooks/useProvisionalLoser';
import { RootStackParamList } from '../navigation/types';
import { RollSource, useGameStore } from '../store/gameStore';
import { formatStreakBadge } from '../services/streakService';
import { presets, textWeight, ui } from '../styles/ui';
import { getRareToastMessage, isVeryRareRoll } from '../utils/probability';
@@ -69,7 +70,8 @@ export function GameScreen({ navigation }: GameScreenProps) {
goToNextPlayer,
openRoundSummary,
resetPlayerDrinks,
resetGame,
returnToSetup,
playerGameStats,
} = useGameStore();
const [rollSignal, setRollSignal] = useState(0);
@@ -97,7 +99,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
const currentPlayer = players[currentPlayerIndex];
const maxRolls = isTieBreak ? 1 : firstPlayerRollLimit ?? 3;
const roundSummaryReady = phase === 'playing' && Boolean(roundSummary);
const displayedPot = roundSummaryReady && roundSummary ? roundSummary.potValue : pot;
const displayedPot = pot;
const canInitialRoll = currentRollCount === 0 && !pendingRoll;
const canReRoll = !isTieBreak && awaitingDecision && currentRollCount < maxRolls && !pendingRoll;
@@ -198,7 +200,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
text: 'Quitter',
style: 'destructive',
onPress: () => {
resetGame();
returnToSetup();
navigation.replace('Setup');
},
},
@@ -209,7 +211,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
return () => {
backSubscription.remove();
};
}, [navigation, resetGame]),
}, [navigation, returnToSetup]),
);
const launchRoll = useCallback(
@@ -506,7 +508,8 @@ export function GameScreen({ navigation }: GameScreenProps) {
drinks={player.drinksConsumed}
lastScore={player.lastScore}
isCurrent={player.id === currentPlayer.id}
onReset={() => resetPlayerDrinks(player.id)}
showReset={false}
streakBadge={formatStreakBadge(playerGameStats[player.id] ?? { recentResults: [] })}
/>
</View>
))}
@@ -596,7 +599,7 @@ const styles = StyleSheet.create({
},
diceWrapper: {
flex: 1,
minHeight: 360,
minHeight: 380,
backgroundColor: ui.color.surfaceDeep,
borderRadius: ui.radius.xxl,
borderWidth: 1,
+7 -4
View File
@@ -15,13 +15,14 @@ import Animated, {
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RootStackParamList } from '../navigation/types';
import { buildPotFormula, formatDrink } from '../services/scoreService';
import { formatStreakBadge } from '../services/streakService';
import { useGameStore } from '../store/gameStore';
import { effects, presets, textWeight, ui } from '../styles/ui';
type ScoreScreenProps = NativeStackScreenProps<RootStackParamList, 'Score'>;
export function ScoreScreen({ navigation }: ScoreScreenProps) {
const { phase, roundSummary, players, startNextRound, resetGame } = useGameStore();
const { phase, roundSummary, players, startNextRound, returnToSetup, playerGameStats } = useGameStore();
const pulse = useSharedValue(1);
useEffect(() => {
@@ -48,7 +49,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
text: 'Quitter',
style: 'destructive',
onPress: () => {
resetGame();
returnToSetup();
navigation.replace('Setup');
},
},
@@ -61,7 +62,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
backSubscription.remove();
};
},
[navigation, resetGame],
[navigation, returnToSetup],
),
);
@@ -114,7 +115,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
};
const goToSetup = () => {
resetGame();
returnToSetup();
navigation.replace('Setup');
};
@@ -164,6 +165,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
roundScore={entry.score}
rollsUsed={entry.rollsUsed}
badgesText={entry.badges.join(' | ') || null}
streakBadge={formatStreakBadge(playerGameStats[entry.playerId] ?? { recentResults: [] })}
showReset={false}
cardStyle={styles.rankingCard}
/>
@@ -185,6 +187,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
lastScore={player.lastScore}
compact
rank={index + 1}
streakBadge={formatStreakBadge(playerGameStats[player.id] ?? { recentResults: [] })}
showReset={false}
cardStyle={styles.rankingCard}
/>
+356 -177
View File
@@ -1,3 +1,4 @@
import { FontAwesome } from '@expo/vector-icons';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
@@ -11,6 +12,9 @@ import {
TextInput,
View,
} from 'react-native';
import DraggableFlatList, {
type RenderItemParams,
} from 'react-native-draggable-flatlist';
import { SafeAreaView } from 'react-native-safe-area-context';
import { QualitySlider } from '../components/QualitySlider';
import { RulesModal } from '../components/RulesModal';
@@ -33,17 +37,48 @@ import { effects, presets, textWeight, ui } from '../styles/ui';
type SetupScreenProps = NativeStackScreenProps<RootStackParamList, 'Setup'>;
type PlayerListItem = {
id: string;
name: string;
magicNumber: string;
theme: string;
};
const AUTO_RESET_INACTIVITY_MS = 24 * 60 * 60 * 1000;
const formatResetCountdown = (milliseconds: number): string => {
const clamped = Math.max(0, milliseconds);
const totalSeconds = Math.floor(clamped / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
export function SetupScreen({ navigation }: SetupScreenProps) {
const setupGame = useGameStore((state) => state.setupGame);
const resetGame = useGameStore((state) => state.resetGame);
const autoResetIfInactive = useGameStore((state) => state.autoResetIfInactive);
const lastGameActivityAt = useGameStore((state) => state.lastGameActivityAt);
const phase = useGameStore((state) => state.phase);
const players = useGameStore((state) => state.players);
const round = useGameStore((state) => state.round);
const pot = useGameStore((state) => state.pot);
const turns = useGameStore((state) => state.turns);
const allRolls = useGameStore((state) => state.allRolls);
const roundSummary = useGameStore((state) => state.roundSummary);
const tieBreakPlayerIds = useGameStore((state) => state.tieBreakPlayerIds);
const tieBreakTurns = useGameStore((state) => state.tieBreakTurns);
const savedProfiles = useGameStore((state) => state.savedProfiles);
const saveProfile = useGameStore((state) => state.saveProfile);
const removeProfile = useGameStore((state) => state.removeProfile);
const persistedMagicNumberEnabled = useGameStore(
(state) => state.persistedMagicNumberEnabled,
);
const setupDraft = useGameStore((state) => state.setupDraft);
const persistentPlayerThemes = useGameStore((state) => state.persistentPlayerThemes);
const setPersistedPlayerTheme = useGameStore((state) => state.setPersistedPlayerTheme);
const persistedQualitySetting = useGameStore((state) => state.persistedQualitySetting);
const setSetupDraft = useGameStore((state) => state.setSetupDraft);
const setPersistedMagicNumberEnabled = useGameStore(
(state) => state.setPersistedMagicNumberEnabled,
);
@@ -51,22 +86,39 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
(state) => state.setPersistedQualitySetting,
);
const [playerNames, setPlayerNames] = useState<string[]>(['Joueur 1', 'Joueur 2']);
const [magicNumbersByPlayer, setMagicNumbersByPlayer] = useState<string[]>(['', '']);
const initialPlayerNames = setupDraft.playerNames.length >= 2
? setupDraft.playerNames
: ['Joueur 1', 'Joueur 2'];
const initialMagicNumbers = setupDraft.magicNumbersByPlayer.length >= 2
? setupDraft.magicNumbersByPlayer
: ['', ''];
const initialThemes = setupDraft.playerThemes.length >= 2
? setupDraft.playerThemes
: persistentPlayerThemes.length > 0
? [...persistentPlayerThemes]
: [DEFAULT_DICE_THEME, DEFAULT_DICE_THEME];
const nextPlayerIdRef = useRef(initialPlayerNames.length + 1);
const [playerNames, setPlayerNames] = useState<string[]>(initialPlayerNames);
const [playerIds, setPlayerIds] = useState<string[]>(
initialPlayerNames.map((_, index) => `player-${index + 1}`),
);
const [magicNumbersByPlayer, setMagicNumbersByPlayer] = useState<string[]>(initialMagicNumbers);
const [playerThemes, setPlayerThemes] = useState<string[]>(
() => persistentPlayerThemes.length > 0 ? [...persistentPlayerThemes] : [DEFAULT_DICE_THEME, DEFAULT_DICE_THEME],
initialThemes,
);
const [profileName, setProfileName] = useState('');
const [profileMagicNumber, setProfileMagicNumber] = useState('');
const [profileTheme, setProfileTheme] = useState(DEFAULT_DICE_THEME);
const [enableShake, setEnableShake] = useState(true);
const [enableRub, setEnableRub] = useState(true);
const [enableMagicNumber, setEnableMagicNumber] = useState(false);
const [endRoundPotFactor, setEndRoundPotFactor] = useState(1);
const [qualitySetting, setQualitySetting] = useState<QualitySetting>('high');
const [enableShake, setEnableShake] = useState(setupDraft.enableShake);
const [enableRub, setEnableRub] = useState(setupDraft.enableRub);
const [enableMagicNumber, setEnableMagicNumber] = useState(setupDraft.enableMagicNumber);
const [endRoundPotFactor, setEndRoundPotFactor] = useState(setupDraft.endRoundPotFactor);
const [qualitySetting, setQualitySetting] = useState<QualitySetting>(setupDraft.qualitySetting);
const [rulesOpen, setRulesOpen] = useState(false);
const [profilesExpanded, setProfilesExpanded] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const [nowTimestamp, setNowTimestamp] = useState(Date.now());
const endRoundPotFactorListRef = useRef<FlatList<EndRoundPotFactorOption>>(null);
const endRoundPotFactorOptions = useMemo(
@@ -75,14 +127,82 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
);
useEffect(() => {
setEnableMagicNumber(persistedMagicNumberEnabled);
}, [persistedMagicNumberEnabled]);
autoResetIfInactive();
const intervalId = setInterval(() => {
setNowTimestamp(Date.now());
autoResetIfInactive();
}, 1000);
return () => {
clearInterval(intervalId);
};
}, [autoResetIfInactive]);
useEffect(() => {
setQualitySetting(persistedQualitySetting);
}, [persistedQualitySetting]);
setSetupDraft({
playerNames,
magicNumbersByPlayer,
playerThemes,
enableShake,
enableRub,
enableMagicNumber,
endRoundPotFactor,
qualitySetting,
});
}, [
playerNames,
magicNumbersByPlayer,
playerThemes,
enableShake,
enableRub,
enableMagicNumber,
endRoundPotFactor,
qualitySetting,
setSetupDraft,
]);
const canStart = useMemo(() => sanitizeNames(playerNames).length >= MIN_PLAYERS, [playerNames]);
const resetCountdownMs = useMemo(() => {
const reference = lastGameActivityAt ?? nowTimestamp;
return Math.max(0, AUTO_RESET_INACTIVITY_MS - (nowTimestamp - reference));
}, [lastGameActivityAt, nowTimestamp]);
const resetCountdownLabel = useMemo(
() => formatResetCountdown(resetCountdownMs),
[resetCountdownMs],
);
const isGameAtDefaultStatus = useMemo(
() =>
phase === 'setup' &&
players.length === 0 &&
round === 1 &&
pot === 0 &&
turns.length === 0 &&
allRolls.length === 0 &&
roundSummary === null &&
tieBreakPlayerIds.length === 0 &&
tieBreakTurns.length === 0,
[
phase,
players.length,
round,
pot,
turns.length,
allRolls.length,
roundSummary,
tieBreakPlayerIds.length,
tieBreakTurns.length,
],
);
const playerItems = useMemo<PlayerListItem[]>(
() =>
playerNames.map((name, index) => ({
id: playerIds[index] ?? `player-${index + 1}`,
name,
magicNumber: magicNumbersByPlayer[index] ?? '',
theme: playerThemes[index] ?? DEFAULT_DICE_THEME,
})),
[playerIds, playerNames, magicNumbersByPlayer, playerThemes],
);
const selectedEndRoundPotFactorIndex = useMemo(() => {
const selectedIndex = endRoundPotFactorOptions.findIndex(
@@ -116,40 +236,34 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
);
};
const createPlayerId = () => {
const nextId = `player-${nextPlayerIdRef.current}`;
nextPlayerIdRef.current += 1;
return nextId;
};
const syncPlayers = (nextPlayers: PlayerListItem[]) => {
setPlayerIds(nextPlayers.map((player) => player.id));
setPlayerNames(nextPlayers.map((player) => player.name));
setMagicNumbersByPlayer(nextPlayers.map((player) => player.magicNumber));
setPlayerThemes(nextPlayers.map((player) => player.theme));
};
const addPlayer = () => {
const nextPlayerId = createPlayerId();
setPlayerIds((previous) => [...previous, nextPlayerId]);
setPlayerNames((previous) => [...previous, `Joueur ${previous.length + 1}`]);
setMagicNumbersByPlayer((previous) => [...previous, '']);
setPlayerThemes((previous) => [...previous, DEFAULT_DICE_THEME]);
};
const movePlayer = (fromIndex: number, toIndex: number) => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= playerNames.length ||
toIndex >= playerNames.length
) {
return;
}
const reorder = <T,>(entries: T[]): T[] => {
const next = [...entries];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
};
setPlayerNames((previous) => reorder(previous));
setMagicNumbersByPlayer((previous) => reorder(previous));
setPlayerThemes((previous) => reorder(previous));
};
const addProfileAsPlayer = (
profileName: string,
profileMagicNumber: number,
profileTheme?: string,
) => {
const nextPlayerId = createPlayerId();
setPlayerIds((previous) => [...previous, nextPlayerId]);
setPlayerNames((previous) => [...previous, profileName]);
setMagicNumbersByPlayer((previous) => [...previous, `${profileMagicNumber}`]);
setPlayerThemes((previous) => [...previous, profileTheme ?? DEFAULT_DICE_THEME]);
@@ -160,6 +274,7 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
return;
}
setPlayerIds((previous) => previous.filter((_, itemIndex) => itemIndex !== index));
setPlayerNames((previous) => previous.filter((_, itemIndex) => itemIndex !== index));
setMagicNumbersByPlayer((previous) =>
previous.filter((_, itemIndex) => itemIndex !== index),
@@ -275,8 +390,119 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
navigation.navigate('Game');
};
const startNewStatsGame = () => {
Alert.alert(
'Remettre les stats à zéro ? ',
'Les statistiques de la partie en cours seront effacées. La configuration restera inchangée.',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Réinitialiser',
style: 'destructive',
onPress: () => {
resetGame();
},
},
],
);
};
const renderPlayerItem = ({
item,
getIndex,
drag,
isActive,
}: RenderItemParams<PlayerListItem>) => {
const index = getIndex() ?? 0;
const playerLabel = item.name.trim() || `Joueur ${index + 1}`;
return (
<View style={styles.playerBlock}>
<View style={styles.playerRow}>
<Pressable
style={[
styles.reorderHandle,
isActive && styles.reorderHandleActive,
]}
onPressIn={drag}
hitSlop={ui.space.xxs}
accessibilityRole="button"
accessibilityLabel={`Réorganiser ${playerLabel}`}
>
<FontAwesome
name="bars"
size={16}
color={isActive ? ui.color.onAccent : ui.color.textSecondary}
/>
</Pressable>
<TextInput
value={item.name}
onChangeText={(value) => updatePlayerName(index, value)}
style={styles.input}
placeholder={`Joueur ${index + 1}`}
placeholderTextColor={ui.color.inputPlaceholder}
/>
{playerNames.length > MIN_PLAYERS && (
<Pressable style={styles.deleteButton} onPress={() => removePlayer(index)}>
<Text style={styles.deleteLabel}>✕</Text>
</Pressable>
)}
</View>
{enableMagicNumber && (
<View style={styles.magicRow}>
<Text style={styles.magicLabel}>Chiffre magique du joueur</Text>
<TextInput
value={item.magicNumber}
onChangeText={(value) => updatePlayerMagicNumber(index, value)}
keyboardType="number-pad"
style={styles.magicInput}
placeholder="11 à 66 (dizaine ≥ unité, sauf 31)"
placeholderTextColor={ui.color.inputPlaceholder}
/>
</View>
)}
<View style={styles.themePickerRow}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.themePickerContent}
>
{DICE_THEMES.map((diceTheme) => {
const selected = item.theme === diceTheme.id;
return (
<Pressable
key={diceTheme.id}
style={[styles.themeChip, selected && styles.themeChipSelected]}
onPress={() => updatePlayerTheme(index, diceTheme.id)}
>
<View
style={[
styles.themeColorSwatch,
{ backgroundColor: diceTheme.previewColor },
selected && styles.themeColorSwatchSelected,
]}
/>
<Text
style={[
styles.themeChipLabel,
selected && styles.themeChipLabelSelected,
]}
>
{diceTheme.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<View style={styles.headerTextBlock}>
@@ -296,95 +522,15 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
</Pressable>
</View>
{playerNames.map((name, index) => (
<View key={`player-${index}`} style={styles.playerBlock}>
<View style={styles.playerRow}>
<View style={styles.reorderButtonsColumn}>
<Pressable
style={[
styles.reorderButton,
index === 0 && styles.reorderButtonDisabled,
]}
disabled={index === 0}
onPress={() => movePlayer(index, index - 1)}
>
<Text style={styles.reorderLabel}>↑</Text>
</Pressable>
<Pressable
style={[
styles.reorderButton,
index === playerNames.length - 1 && styles.reorderButtonDisabled,
]}
disabled={index === playerNames.length - 1}
onPress={() => movePlayer(index, index + 1)}
>
<Text style={styles.reorderLabel}>↓</Text>
</Pressable>
</View>
<TextInput
value={name}
onChangeText={(value) => updatePlayerName(index, value)}
style={styles.input}
placeholder={`Joueur ${index + 1}`}
placeholderTextColor={ui.color.inputPlaceholder}
/>
{playerNames.length > MIN_PLAYERS && (
<Pressable style={styles.deleteButton} onPress={() => removePlayer(index)}>
<Text style={styles.deleteLabel}>✕</Text>
</Pressable>
)}
</View>
{enableMagicNumber && (
<View style={styles.magicRow}>
<Text style={styles.magicLabel}>Chiffre magique du joueur</Text>
<TextInput
value={magicNumbersByPlayer[index] ?? ''}
onChangeText={(value) => updatePlayerMagicNumber(index, value)}
keyboardType="number-pad"
style={styles.magicInput}
placeholder="11 à 66 (dizaine ≥ unité, sauf 31)"
placeholderTextColor={ui.color.inputPlaceholder}
/>
</View>
)}
<View style={styles.themePickerRow}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.themePickerContent}
>
{DICE_THEMES.map((diceTheme) => {
const selected = (playerThemes[index] ?? DEFAULT_DICE_THEME) === diceTheme.id;
return (
<Pressable
key={diceTheme.id}
style={[styles.themeChip, selected && styles.themeChipSelected]}
onPress={() => updatePlayerTheme(index, diceTheme.id)}
>
<View
style={[
styles.themeColorSwatch,
{ backgroundColor: diceTheme.previewColor },
selected && styles.themeColorSwatchSelected,
]}
/>
<Text
style={[
styles.themeChipLabel,
selected && styles.themeChipLabelSelected,
]}
>
{diceTheme.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
))}
<DraggableFlatList
data={playerItems}
onDragEnd={({ data }) => syncPlayers(data)}
keyExtractor={(item) => item.id}
renderItem={renderPlayerItem}
scrollEnabled={false}
activationDistance={8}
dragItemOverflow={false}
/>
</View>
<View style={styles.card}>
@@ -475,6 +621,18 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
) : (
savedProfiles.map((profile) => (
<View key={profile.name} style={styles.profileRow}>
<Pressable
style={styles.profileAddButton}
onPress={() =>
addProfileAsPlayer(
profile.name,
profile.magicNumber,
profile.theme,
)
}
>
<Text style={styles.profileAddLabel}>+</Text>
</Pressable>
<View style={styles.profileInfo}>
<Text style={styles.profileNameText}>{profile.name}</Text>
<View style={styles.profileMetaRow}>
@@ -497,19 +655,6 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
</View>
</View>
<Pressable
style={styles.profileAddButton}
onPress={() =>
addProfileAsPlayer(
profile.name,
profile.magicNumber,
profile.theme,
)
}
>
<Text style={styles.profileAddLabel}>+</Text>
</Pressable>
<Pressable
style={styles.profileDeleteButton}
onPress={() => removeProfile(profile.name)}
@@ -629,30 +774,48 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
)}
</View>
<Pressable
style={[
styles.actionButtonBase,
styles.primaryButton,
!canStart && styles.primaryButtonDisabled,
]}
onPress={startGame}
disabled={!canStart}
>
<Text style={[styles.actionButtonLabel, styles.primaryLabel]}>
Démarrer la partie
</Text>
</Pressable>
<Pressable
style={[styles.actionButtonBase, styles.leaderboardButton]}
onPress={() => navigation.navigate('Leaderboard')}
>
<Text style={[styles.actionButtonLabel, styles.leaderboardLabel]}>
Leaderboard all-time
</Text>
</Pressable>
</ScrollView>
<View style={styles.stickyFooter}>
<View style={styles.stickyRow}>
<Pressable
style={[
styles.actionButtonBase,
styles.leaderboardButton,
styles.stickyButton,
]}
onPress={() => navigation.navigate('Leaderboard')}
>
<Text style={[styles.actionButtonLabel, styles.leaderboardLabel]}>
Leaderboard all-time
</Text>
</Pressable>
<Pressable
style={[
styles.actionButtonBase,
styles.primaryButton,
styles.stickyButton,
!canStart && styles.primaryButtonDisabled,
]}
onPress={startGame}
disabled={!canStart}
>
<Text style={[styles.actionButtonLabel, styles.primaryLabel]}>
Démarrer la partie
</Text>
</Pressable>
</View>
{!isGameAtDefaultStatus && (
<Pressable style={styles.newStatsButton} onPress={startNewStatsGame}>
<Text style={styles.newStatsLabel}>
{`Nouvelle partie (stats à zéro) • auto reset ${resetCountdownLabel}`}
</Text>
</Pressable>
)}
</View>
<RulesModal visible={rulesOpen} onClose={() => setRulesOpen(false)} />
</SafeAreaView>
);
@@ -664,6 +827,7 @@ const styles = StyleSheet.create({
},
content: {
...presets.screen.content,
paddingBottom: ui.space.xxxl + 96,
},
headerRow: {
flexDirection: 'row',
@@ -710,10 +874,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
},
reorderButtonsColumn: {
reorderHandle: {
marginRight: ui.space.xs,
},
reorderButton: {
width: ui.size.iconButton,
height: ui.size.iconButton,
borderRadius: ui.radius.md,
@@ -722,16 +884,10 @@ const styles = StyleSheet.create({
backgroundColor: ui.color.surfaceAlt,
borderWidth: 1,
borderColor: ui.color.borderStrong,
marginBottom: ui.space.xxs,
},
reorderButtonDisabled: {
opacity: ui.opacity.disabled,
},
reorderLabel: {
color: ui.color.textPrimary,
fontWeight: textWeight.bold,
fontSize: ui.fontSize.sm,
lineHeight: ui.lineHeight.sm,
reorderHandleActive: {
backgroundColor: ui.color.accent,
borderColor: ui.color.accentBorder,
},
playerBlock: {
marginBottom: ui.space.sm,
@@ -912,7 +1068,30 @@ const styles = StyleSheet.create({
},
actionButtonBase: {
...presets.button.prominent,
marginBottom: ui.space.xs,
},
stickyFooter: {
paddingHorizontal: ui.space.md,
paddingTop: ui.space.xs,
paddingBottom: ui.space.xs,
backgroundColor: ui.color.bg,
borderTopWidth: 1,
borderTopColor: ui.color.border,
},
stickyRow: {
flexDirection: 'row',
columnGap: ui.space.xs,
},
stickyButton: {
flex: 1,
marginBottom: 0,
},
newStatsButton: {
...presets.button.compact,
...presets.button.toneDanger,
marginTop: ui.space.xs,
},
newStatsLabel: {
...presets.text.buttonDanger,
},
actionButtonLabel: {
...presets.text.button,
+85
View File
@@ -0,0 +1,85 @@
import type { PlayerGameStats, PlayerGameResult } from '../store/gameStore';
export type StreakType = 'hot' | 'cold' | null;
const MIN_VISIBLE_STREAK = 3;
export interface PlayerStreak {
type: StreakType;
count: number;
}
/**
* Calculate the current hot or cold streak for a player.
* - Hot streak: consecutive wins (including tie-break wins)
* - Cold streak: consecutive losses (including tie-break losses)
*
* @param stats - Player game statistics
* @returns Streak information or null if no streak exists
*/
export function calculatePlayerStreak(stats: PlayerGameStats): PlayerStreak {
if (!stats.recentResults || stats.recentResults.length === 0) {
return { type: null, count: 0 };
}
const results = stats.recentResults;
const lastResult = results[results.length - 1];
// Determine if the last result is a win or loss
const isWin = lastResult === 'win' || lastResult === 'tie-break-win';
if (!isWin) {
// Count consecutive losses from the end
let count = 0;
for (let i = results.length - 1; i >= 0; i--) {
const result = results[i];
if (result === 'loss' || result === 'tie-break-loss') {
count++;
} else {
break;
}
}
return count > 0 ? { type: 'cold', count } : { type: null, count: 0 };
}
// Count consecutive wins from the end
let count = 0;
for (let i = results.length - 1; i >= 0; i--) {
const result = results[i];
if (result === 'win' || result === 'tie-break-win') {
count++;
} else {
break;
}
}
return count > 0 ? { type: 'hot', count } : { type: null, count: 0 };
}
/**
* Get emoji representation of a streak
*/
export function getStreakEmoji(type: StreakType): string {
switch (type) {
case 'hot':
return '🔥';
case 'cold':
return '❄️';
case null:
return '';
default:
return '';
}
}
/**
* Get a formatted streak badge string
*/
export function formatStreakBadge(stats: PlayerGameStats): string {
const streak = calculatePlayerStreak(stats);
if (streak.count < MIN_VISIBLE_STREAK) {
return '';
}
const emoji = getStreakEmoji(streak.type);
return `${emoji}x${streak.count}`;
}
+208 -18
View File
@@ -108,6 +108,14 @@ export type SavedProfile = {
isDefault: boolean;
};
export type PlayerGameResult = 'win' | 'loss' | 'tie-break-win' | 'tie-break-loss';
export type PlayerGameStats = {
recentResults: PlayerGameResult[];
};
const AUTO_RESET_INACTIVITY_MS = 24 * 60 * 60 * 1000;
type SetupPayload = {
playerNames: string[];
magicNumbers: (number | null)[];
@@ -115,6 +123,17 @@ type SetupPayload = {
settings: GameSettings;
};
export type SetupDraft = {
playerNames: string[];
magicNumbersByPlayer: string[];
playerThemes: string[];
enableShake: boolean;
enableRub: boolean;
enableMagicNumber: boolean;
endRoundPotFactor: number;
qualitySetting: QualitySetting;
};
type GameStoreState = {
phase: GamePhase;
players: Player[];
@@ -140,11 +159,14 @@ type GameStoreState = {
allRolls: RollRecord[];
playerRollStats: Record<string, PlayerRollStats>;
rareEvents: RareStatEvent[];
playerGameStats: Record<string, PlayerGameStats>;
lastGameActivityAt: number | null;
tieBreakPlayerIds: string[];
tieBreakTurns: TurnResult[];
tieBreakRound: number;
tieBreakBaseTurns: TurnResult[];
tieBreakEndedBy31: boolean;
autoResetIfInactive: () => boolean;
setupGame: (payload: SetupPayload) => void;
requestRoll: (source?: RollSource) => boolean;
receiveRollResult: (dice: DicePair) => RollOutcome | null;
@@ -159,8 +181,11 @@ type GameStoreState = {
setPersistedQualitySetting: (qualitySetting: QualitySetting) => void;
persistentPlayerThemes: string[];
setPersistedPlayerTheme: (index: number, theme: string) => void;
setupDraft: SetupDraft;
setSetupDraft: (draft: SetupDraft) => void;
startNextRound: () => void;
openRoundSummary: () => void;
returnToSetup: () => void;
resetGame: () => void;
};
@@ -172,6 +197,17 @@ const defaultSettings: GameSettings = {
qualitySetting: 'high',
};
const defaultSetupDraft: SetupDraft = {
playerNames: ['Joueur 1', 'Joueur 2'],
magicNumbersByPlayer: ['', ''],
playerThemes: [DEFAULT_DICE_THEME, DEFAULT_DICE_THEME],
enableShake: defaultSettings.enableShake,
enableRub: defaultSettings.enableRub,
enableMagicNumber: defaultSettings.enableMagicNumber,
endRoundPotFactor: defaultSettings.endRoundPotFactor,
qualitySetting: defaultSettings.qualitySetting,
};
export const getScoreFromDice = (dice: DicePair): number => {
const [first, second] = dice;
const high = Math.max(first, second);
@@ -324,9 +360,12 @@ const getInitialState = (): Omit<
| 'setPersistedMagicNumberEnabled'
| 'setPersistedQualitySetting'
| 'setPersistedPlayerTheme'
| 'setSetupDraft'
| 'startNextRound'
| 'openRoundSummary'
| 'returnToSetup'
| 'resetGame'
| 'autoResetIfInactive'
> => ({
phase: 'setup',
players: [],
@@ -336,6 +375,7 @@ const getInitialState = (): Omit<
persistentPlayerThemes: [],
persistedMagicNumberEnabled: false,
persistedQualitySetting: 'high',
setupDraft: defaultSetupDraft,
round: 1,
pot: 0,
currentPlayerIndex: 0,
@@ -353,6 +393,8 @@ const getInitialState = (): Omit<
allRolls: [],
playerRollStats: {},
rareEvents: [],
playerGameStats: {},
lastGameActivityAt: Date.now(),
tieBreakPlayerIds: [],
tieBreakTurns: [],
tieBreakRound: 0,
@@ -366,6 +408,19 @@ const createEmptyPlayerRollStats = (): PlayerRollStats => ({
roundScoreCounts: {},
});
const createEmptyPlayerGameStats = (): PlayerGameStats => ({
recentResults: [],
});
const addGameResult = (stats: PlayerGameStats, result: PlayerGameResult): PlayerGameStats => {
const updated = [...stats.recentResults, result];
// Keep only the last 5 results
if (updated.length > 5) {
updated.shift();
}
return { recentResults: updated };
};
const getPersistFileUri = (storageKey: string): string | null => {
const baseDirectory = FileSystem.documentDirectory ?? FileSystem.cacheDirectory;
if (!baseDirectory) {
@@ -591,6 +646,7 @@ export const useGameStore = create<GameStoreState>()(
turns: TurnResult[],
endedBy31: boolean,
loserIdOverride?: string,
isTieBreak: boolean = false,
) => {
const state = get();
if (turns.length === 0) {
@@ -618,6 +674,20 @@ export const useGameStore = create<GameStoreState>()(
);
const potValue = computeEndRoundPotValue(basePotValue, appliedPotFactor);
// Update game results for hot/cold streak tracking
const nextPlayerGameStats = { ...state.playerGameStats };
const winResult = isTieBreak ? 'tie-break-win' : 'win';
const lossResult = isTieBreak ? 'tie-break-loss' : 'loss';
// Only the gross loser gets a loss; every other player gets a win
standings.forEach((entry) => {
const result = entry.playerId === loser.playerId ? lossResult : winResult;
nextPlayerGameStats[entry.playerId] = addGameResult(
nextPlayerGameStats[entry.playerId] ?? createEmptyPlayerGameStats(),
result,
);
});
set({
phase: 'playing',
pot: basePotValue,
@@ -630,6 +700,7 @@ export const useGameStore = create<GameStoreState>()(
tieBreakEndedBy31: false,
lastRollSource: null,
roundSummaryApplied: false,
playerGameStats: nextPlayerGameStats,
roundSummary: {
winnerName: winner.playerName,
loserName: loser.playerName,
@@ -752,7 +823,36 @@ export const useGameStore = create<GameStoreState>()(
return {
...getInitialState(),
autoResetIfInactive: () => {
const state = get();
const now = Date.now();
const lastActivityAt = state.lastGameActivityAt ?? now;
if (now - lastActivityAt < AUTO_RESET_INACTIVITY_MS) {
return false;
}
set({
...getInitialState(),
allTimeLeaderboard: state.allTimeLeaderboard,
savedProfiles: state.savedProfiles,
persistedMagicNumberEnabled: state.persistedMagicNumberEnabled,
persistedQualitySetting: state.persistedQualitySetting,
persistentPlayerThemes: state.persistentPlayerThemes,
setupDraft: state.setupDraft,
settings: {
...defaultSettings,
enableMagicNumber: state.persistedMagicNumberEnabled,
qualitySetting: state.persistedQualitySetting,
},
lastGameActivityAt: now,
});
return true;
},
setupGame: ({ playerNames, magicNumbers, playerThemes, settings }) => {
const previousState = get();
const cleanNames = playerNames.map((name) => name.trim()).filter(Boolean);
if (cleanNames.length < 2) {
return;
@@ -766,25 +866,40 @@ export const useGameStore = create<GameStoreState>()(
qualitySetting: normalizeQualitySetting(settings.qualitySetting),
};
const players = cleanNames.map((name, index) => ({
id: createPlayerId(name, index),
name,
theme: isDiceTheme(playerThemes[index]) ? playerThemes[index] : DEFAULT_DICE_THEME,
magicNumber: normalizedSettings.enableMagicNumber
? normalizeMagicNumber(magicNumbers[index] ?? null)
: null,
drinksConsumed: 0,
lastScore: null,
lastDice: null,
}));
const previousPlayers = [...previousState.players];
const players = cleanNames.map((name, index) => {
const previousIndex = previousPlayers.findIndex((player) => player.name === name);
const previousPlayer = previousIndex >= 0 ? previousPlayers[previousIndex] : null;
if (previousIndex >= 0) {
previousPlayers.splice(previousIndex, 1);
}
return {
id: previousPlayer?.id ?? createPlayerId(name, index),
name,
theme: isDiceTheme(playerThemes[index]) ? playerThemes[index] : DEFAULT_DICE_THEME,
magicNumber: normalizedSettings.enableMagicNumber
? normalizeMagicNumber(magicNumbers[index] ?? null)
: null,
drinksConsumed: previousPlayer?.drinksConsumed ?? 0,
lastScore: null,
lastDice: null,
};
});
const playerIds = new Set(players.map((player) => player.id));
const playerRollStats = players.reduce<Record<string, PlayerRollStats>>(
(accumulator, player) => {
accumulator[player.id] = createEmptyPlayerRollStats();
accumulator[player.id] =
previousState.playerRollStats[player.id] ?? createEmptyPlayerRollStats();
return accumulator;
},
{},
);
const allRolls = previousState.allRolls.filter((roll) => playerIds.has(roll.playerId));
const rareEvents = previousState.rareEvents.filter((event) =>
playerIds.has(event.playerId),
);
set({
phase: 'playing',
@@ -807,14 +922,15 @@ export const useGameStore = create<GameStoreState>()(
roundSummary: null,
roundSummaryApplied: false,
lastRollSource: null,
allRolls: [],
allRolls,
playerRollStats,
rareEvents: [],
rareEvents,
tieBreakPlayerIds: [],
tieBreakTurns: [],
tieBreakRound: 0,
tieBreakBaseTurns: [],
tieBreakEndedBy31: false,
lastGameActivityAt: Date.now(),
});
},
@@ -894,6 +1010,7 @@ export const useGameStore = create<GameStoreState>()(
score,
probability,
);
set({ lastGameActivityAt: Date.now() });
if (state.phase === 'tie-break') {
const tieTurn: TurnResult = {
@@ -941,10 +1058,11 @@ export const useGameStore = create<GameStoreState>()(
}),
});
} else {
const lowestScore = Math.min(...updatedTieTurns.map((turn) => turn.score));
const tiedAgainIds = updatedTieTurns
.filter((turn) => turn.score === lowestScore)
.map((turn) => turn.playerId);
const tieBreakStandings = rankEntries(updatedTieTurns, {
enableMagicNumber: false,
magicNumberByPlayerId: {},
});
const tiedAgainIds = getLowestRankedPlayerIds(tieBreakStandings);
if (tiedAgainIds.length === 1) {
set({
@@ -967,6 +1085,7 @@ export const useGameStore = create<GameStoreState>()(
state.tieBreakBaseTurns,
state.tieBreakEndedBy31,
tiedAgainIds[0],
true,
);
} else {
const firstNextId = tiedAgainIds[0];
@@ -1226,6 +1345,25 @@ export const useGameStore = create<GameStoreState>()(
});
},
setSetupDraft: (draft) => {
set({
setupDraft: {
...draft,
playerNames: draft.playerNames.length >= 2 ? draft.playerNames : ['Joueur 1', 'Joueur 2'],
magicNumbersByPlayer:
draft.magicNumbersByPlayer.length >= 2
? draft.magicNumbersByPlayer
: ['', ''],
playerThemes:
draft.playerThemes.length >= 2
? draft.playerThemes
: [DEFAULT_DICE_THEME, DEFAULT_DICE_THEME],
endRoundPotFactor: normalizeEndRoundPotFactor(draft.endRoundPotFactor),
qualitySetting: normalizeQualitySetting(draft.qualitySetting),
},
});
},
startNextRound: () => {
const state = get();
if (state.players.length < 2) {
@@ -1309,12 +1447,39 @@ export const useGameStore = create<GameStoreState>()(
players: nextPlayers,
allTimeLeaderboard: nextAllTimeLeaderboard,
roundSummaryApplied: true,
lastGameActivityAt: Date.now(),
pendingRoll: false,
awaitingDecision: false,
awaitingNextPlayer: false,
});
},
returnToSetup: () => {
set((state) => ({
phase: 'setup',
pendingRoll: false,
awaitingDecision: false,
awaitingNextPlayer: false,
currentRollCount: 0,
currentScore: null,
currentDice: null,
lastRollSource: null,
tieBreakPlayerIds: [],
tieBreakTurns: [],
tieBreakRound: 0,
tieBreakBaseTurns: [],
tieBreakEndedBy31: false,
roundSummary: null,
roundSummaryApplied: false,
turns: [],
settings: {
...state.settings,
enableMagicNumber: state.persistedMagicNumberEnabled,
qualitySetting: state.persistedQualitySetting,
},
}));
},
resetGame: () => {
set((state) => ({
...getInitialState(),
@@ -1323,11 +1488,13 @@ export const useGameStore = create<GameStoreState>()(
persistedMagicNumberEnabled: state.persistedMagicNumberEnabled,
persistedQualitySetting: state.persistedQualitySetting,
persistentPlayerThemes: state.persistentPlayerThemes,
setupDraft: state.setupDraft,
settings: {
...defaultSettings,
enableMagicNumber: state.persistedMagicNumberEnabled,
qualitySetting: state.persistedQualitySetting,
},
lastGameActivityAt: Date.now(),
}));
},
};
@@ -1336,11 +1503,34 @@ export const useGameStore = create<GameStoreState>()(
name: 'game-store',
storage: createJSONStorage(() => fileStateStorage),
partialize: (state) => ({
// Configuration and profiles
allTimeLeaderboard: state.allTimeLeaderboard,
savedProfiles: state.savedProfiles,
persistedMagicNumberEnabled: state.persistedMagicNumberEnabled,
persistedQualitySetting: state.persistedQualitySetting,
persistentPlayerThemes: state.persistentPlayerThemes,
setupDraft: state.setupDraft,
// Current game state
phase: state.phase,
players: state.players,
settings: state.settings,
round: state.round,
pot: state.pot,
currentPlayerIndex: state.currentPlayerIndex,
turns: state.turns,
roundSummary: state.roundSummary,
roundSummaryApplied: state.roundSummaryApplied,
tieBreakPlayerIds: state.tieBreakPlayerIds,
tieBreakTurns: state.tieBreakTurns,
tieBreakRound: state.tieBreakRound,
tieBreakBaseTurns: state.tieBreakBaseTurns,
tieBreakEndedBy31: state.tieBreakEndedBy31,
// Player statistics
playerRollStats: state.playerRollStats,
allRolls: state.allRolls,
rareEvents: state.rareEvents,
playerGameStats: state.playerGameStats,
lastGameActivityAt: state.lastGameActivityAt,
}),
},
),
+1 -1
View File
@@ -1,2 +1,2 @@
export { useGameStore } from './gameStore';
export type { GameSettings, RollSource } from './gameStore';
export type { GameSettings, RollSource, PlayerGameStats, PlayerGameResult } from './gameStore';
+2
View File
@@ -18,6 +18,7 @@ const players: Player[] = [
{
id: 'a',
name: 'Alice',
theme: 'default',
magicNumber: null,
drinksConsumed: 0,
lastScore: null,
@@ -26,6 +27,7 @@ const players: Player[] = [
{
id: 'b',
name: 'Bob',
theme: 'default',
magicNumber: null,
drinksConsumed: 0,
lastScore: null,