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:
1 parent
534e495165
commit
23be3d245e
12 files changed
+808
-214
No files matched your search
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export { useGameStore } from './gameStore';
|
||||
export type { GameSettings, RollSource } from './gameStore';
|
||||
export type { GameSettings, RollSource, PlayerGameStats, PlayerGameResult } from './gameStore';
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user