feat: add initial setup for coverage checking, ESLint configuration, and error boundary component
- Introduced `bunfig.toml` for test preload configuration. - Added ESLint configuration file for code linting. - Implemented a coverage checking script to ensure test coverage meets the specified threshold. - Created `AppErrorBoundary` component to handle errors gracefully in the application. - Defined game constants for player requirements and magic number constraints. - Developed `useProvisionalLoser` hook to compute the provisional loser based on game state. - Implemented services for parsing dice payloads, managing leaderboard entries, and ranking players. - Added utility functions for score formatting and pot calculations. - Created setup service for validating game start inputs and player profiles. - Wrote comprehensive tests for components, hooks, and services to ensure functionality and reliability.
This commit is contained in:
1 parent
37af018378
commit
9e9f81ba57
34 files changed
+2833
-449
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Example environment variables
|
||||
# Copy this file to `.env` and fill with non-sensitive local values.
|
||||
|
||||
EXPO_PUBLIC_APP_ENV=development
|
||||
@@ -31,8 +31,12 @@ yarn-error.*
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# test coverage
|
||||
coverage/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AppErrorBoundary } from './src/components/AppErrorBoundary';
|
||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||
import { ui } from './src/styles/ui';
|
||||
|
||||
@@ -10,14 +11,16 @@ export default function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer>
|
||||
<StatusBar
|
||||
style="light"
|
||||
translucent={false}
|
||||
backgroundColor={ui.color.bg}
|
||||
/>
|
||||
<AppNavigator />
|
||||
</NavigationContainer>
|
||||
<AppErrorBoundary>
|
||||
<NavigationContainer>
|
||||
<StatusBar
|
||||
style="light"
|
||||
translucent={false}
|
||||
backgroundColor={ui.color.bg}
|
||||
/>
|
||||
<AppNavigator />
|
||||
</NavigationContainer>
|
||||
</AppErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Jeu du 21 (Expo + React Native)
|
||||
|
||||
Application mobile du jeu du 21, structurée pour un usage production avec une stratégie de non-régression via `bun:test`.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Bun `>= 1.3`
|
||||
- Node (pour Expo CLI), sinon installer via `nvm install latest`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
## Lancer l'app
|
||||
|
||||
```bash
|
||||
bun run start
|
||||
bun run android
|
||||
```
|
||||
|
||||
## Qualité logicielle
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run test:coverage
|
||||
```
|
||||
|
||||
Le script `test:coverage` applique un seuil global de couverture minimal à `90%` (lignes, branches, fonctions).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/screens`: écrans de navigation
|
||||
- `src/components`: composants UI réutilisables
|
||||
- `src/services`: logique métier pure (testable)
|
||||
- `src/hooks`: hooks applicatifs isolés
|
||||
- `src/constants`: constantes de domaine
|
||||
- `src/store`: état global de jeu (`zustand`)
|
||||
|
||||
## Build production (EAS)
|
||||
|
||||
```bash
|
||||
bun x eas build --platform android --profile production
|
||||
```
|
||||
|
||||
## Sécurité & config
|
||||
|
||||
- Ne pas committer de secrets dans `.env`
|
||||
- Utiliser `.env.example` comme référence
|
||||
- Exécuter `bun run ci:quality` avant toute release
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
}
|
||||
]);
|
||||
+17
-1
@@ -4,7 +4,13 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android"
|
||||
"android": "expo start --android",
|
||||
"lint": "bun x expo lint",
|
||||
"typecheck": "bun x tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"coverage:check": "bun run scripts/check-coverage.ts",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov && bun run coverage:check",
|
||||
"ci:quality": "bun run lint && bun run typecheck && bun run test:coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@3d-dice/dice-box": "^1.1.4",
|
||||
@@ -26,7 +32,17 @@
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "~19.2.14",
|
||||
"bun-types": "^1.3.11",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "^55.0.0",
|
||||
"jest": "^30.3.0",
|
||||
"jest-expo": "^55.0.13",
|
||||
"prettier": "^3.8.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
type CoverageMetric = {
|
||||
hit: number;
|
||||
found: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
const threshold = Number(process.env.COVERAGE_THRESHOLD ?? 90);
|
||||
const lcovFile = resolve(process.cwd(), 'coverage/lcov.info');
|
||||
|
||||
if (!existsSync(lcovFile)) {
|
||||
console.error('[coverage] coverage/lcov.info not found. Run `bun test --coverage` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(lcovFile, 'utf8');
|
||||
const records = content.split('end_of_record');
|
||||
|
||||
const isTestFile = (sourceFilePath: string): boolean => {
|
||||
return (
|
||||
sourceFilePath.startsWith('test/') ||
|
||||
sourceFilePath.includes('/test/') ||
|
||||
sourceFilePath.includes('\\test\\')
|
||||
);
|
||||
};
|
||||
|
||||
let linesFound = 0;
|
||||
let linesHit = 0;
|
||||
let branchesFound = 0;
|
||||
let branchesHit = 0;
|
||||
let functionsFound = 0;
|
||||
let functionsHit = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const lines = record
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFileLine = lines.find((line) => line.startsWith('SF:'));
|
||||
if (!sourceFileLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFilePath = sourceFileLine.slice(3);
|
||||
if (isTestFile(sourceFilePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('LF:')) {
|
||||
linesFound += Number(line.slice(3));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('LH:')) {
|
||||
linesHit += Number(line.slice(3));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BRF:')) {
|
||||
branchesFound += Number(line.slice(4));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BRH:')) {
|
||||
branchesHit += Number(line.slice(4));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('FNF:')) {
|
||||
functionsFound += Number(line.slice(4));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('FNH:')) {
|
||||
functionsHit += Number(line.slice(4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toMetric = (hit: number, found: number): CoverageMetric => ({
|
||||
hit,
|
||||
found,
|
||||
percent: found === 0 ? 100 : (hit / found) * 100,
|
||||
});
|
||||
|
||||
const metrics = {
|
||||
lines: toMetric(linesHit, linesFound),
|
||||
branches: toMetric(branchesHit, branchesFound),
|
||||
functions: toMetric(functionsHit, functionsFound),
|
||||
};
|
||||
|
||||
const failing = Object.entries(metrics).filter(([, metric]) => metric.percent < threshold);
|
||||
|
||||
for (const [name, metric] of Object.entries(metrics)) {
|
||||
console.log(
|
||||
`[coverage] ${name}: ${metric.percent.toFixed(2)}% (${metric.hit}/${metric.found})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failing.length > 0) {
|
||||
console.error(
|
||||
`[coverage] Minimum ${threshold}% not reached for: ${failing
|
||||
.map(([name]) => name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[coverage] ✅ Global threshold ${threshold}% reached.`);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component, ReactNode } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { presets, textWeight, ui } from '../styles/ui';
|
||||
|
||||
type AppErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AppErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
|
||||
public state: AppErrorBoundaryState = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error): void {
|
||||
console.error('[AppErrorBoundary]', error);
|
||||
}
|
||||
|
||||
private retry = (): void => {
|
||||
this.setState({ hasError: false });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Une erreur est survenue</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Relance la vue pour continuer la partie sans fermer l’application.
|
||||
</Text>
|
||||
<Pressable style={styles.retryButton} onPress={this.retry}>
|
||||
<Text style={styles.retryLabel}>Réessayer</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...presets.screen.container,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: ui.space.md,
|
||||
},
|
||||
card: {
|
||||
...presets.card.base,
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
},
|
||||
title: {
|
||||
...presets.text.sectionTitle,
|
||||
marginBottom: ui.space.xs,
|
||||
},
|
||||
subtitle: {
|
||||
...presets.text.body,
|
||||
marginBottom: ui.space.md,
|
||||
},
|
||||
retryButton: {
|
||||
...presets.button.prominent,
|
||||
...presets.button.tonePrimary,
|
||||
},
|
||||
retryLabel: {
|
||||
...presets.text.button,
|
||||
fontWeight: textWeight.bold,
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Asset } from 'expo-asset';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, LayoutChangeEvent, StyleSheet, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import { parseDicePayload } from '../services/dicePayloadService';
|
||||
import { ui } from '../styles/ui';
|
||||
|
||||
type Dice3DViewerProps = {
|
||||
@@ -10,28 +11,6 @@ type Dice3DViewerProps = {
|
||||
onReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
const parseDicePayload = (payload: unknown): [number, number] | null => {
|
||||
if (!Array.isArray(payload) || payload.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = Number(payload[0]);
|
||||
const second = Number(payload[1]);
|
||||
|
||||
if (
|
||||
!Number.isInteger(first) ||
|
||||
!Number.isInteger(second) ||
|
||||
first < 1 ||
|
||||
first > 6 ||
|
||||
second < 1 ||
|
||||
second > 6
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [first, second];
|
||||
};
|
||||
|
||||
export function Dice3DViewer({
|
||||
rollSignal,
|
||||
onRollResult,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const MIN_PLAYERS = 2;
|
||||
|
||||
export const MAGIC_NUMBER_MIN = 11;
|
||||
export const MAGIC_NUMBER_MAX = 66;
|
||||
export const MAGIC_NUMBER_FORBIDDEN = 31;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMemo } from 'react';
|
||||
import { rankEntries } from '../services/rankingService';
|
||||
import type {
|
||||
GamePhase,
|
||||
GameSettings,
|
||||
Player,
|
||||
RoundSummary,
|
||||
TurnResult,
|
||||
} from '../store/gameStore';
|
||||
|
||||
type ProvisionalLoser = {
|
||||
name: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type ProvisionalLoserInput = {
|
||||
phase: GamePhase;
|
||||
roundSummary: RoundSummary | null;
|
||||
players: Player[];
|
||||
turns: TurnResult[];
|
||||
settings: GameSettings;
|
||||
currentScore: number | null;
|
||||
currentRollCount: number;
|
||||
currentPlayer: Player | undefined;
|
||||
};
|
||||
|
||||
type PreviewTurn = Pick<TurnResult, 'playerId' | 'playerName' | 'score'>;
|
||||
|
||||
const createMagicMap = (players: Player[]): Record<string, number | null> => {
|
||||
return players.reduce<Record<string, number | null>>((accumulator, player) => {
|
||||
accumulator[player.id] = player.magicNumber;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const computeProvisionalLoser = ({
|
||||
phase,
|
||||
roundSummary,
|
||||
players,
|
||||
turns,
|
||||
settings,
|
||||
currentScore,
|
||||
currentRollCount,
|
||||
currentPlayer,
|
||||
}: ProvisionalLoserInput): ProvisionalLoser | null => {
|
||||
if (phase === 'round-summary' && roundSummary) {
|
||||
const loserStanding = roundSummary.standings[roundSummary.standings.length - 1];
|
||||
if (!loserStanding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: loserStanding.playerName,
|
||||
score: loserStanding.score,
|
||||
};
|
||||
}
|
||||
|
||||
if (phase !== 'playing') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewTurns: PreviewTurn[] = turns.map((turn) => ({
|
||||
playerId: turn.playerId,
|
||||
playerName: turn.playerName,
|
||||
score: turn.score,
|
||||
}));
|
||||
|
||||
if (currentScore !== null && currentRollCount > 0 && currentPlayer) {
|
||||
const hasCurrentPlayerAlready = previewTurns.some(
|
||||
(turn) => turn.playerId === currentPlayer.id,
|
||||
);
|
||||
|
||||
if (!hasCurrentPlayerAlready) {
|
||||
previewTurns.push({
|
||||
playerId: currentPlayer.id,
|
||||
playerName: currentPlayer.name,
|
||||
score: currentScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (previewTurns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rankedPreview = rankEntries(previewTurns, {
|
||||
enableMagicNumber: settings.enableMagicNumber,
|
||||
magicNumberByPlayerId: createMagicMap(players),
|
||||
});
|
||||
|
||||
const loserPreview = rankedPreview[rankedPreview.length - 1];
|
||||
if (!loserPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: loserPreview.playerName,
|
||||
score: loserPreview.score,
|
||||
};
|
||||
};
|
||||
|
||||
export const useProvisionalLoser = (input: ProvisionalLoserInput): ProvisionalLoser | null => {
|
||||
return useMemo(() => computeProvisionalLoser(input), [input]);
|
||||
};
|
||||
+11
-126
@@ -16,8 +16,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Dice3DViewer } from '../components/Dice3DViewer';
|
||||
import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||
import { RulesModal } from '../components/RulesModal';
|
||||
import { useProvisionalLoser } from '../hooks/useProvisionalLoser';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { RollSource, TurnResult, useGameStore } from '../store/gameStore';
|
||||
import { RollSource, useGameStore } from '../store/gameStore';
|
||||
import { presets, textWeight, ui } from '../styles/ui';
|
||||
import { getRareToastMessage, isVeryRareRoll } from '../utils/probability';
|
||||
|
||||
@@ -37,52 +38,6 @@ const createRubTracker = (): RubTracker => ({
|
||||
switches: 0,
|
||||
});
|
||||
|
||||
const getDoubleRankTier = (score: number): number | null => {
|
||||
switch (score) {
|
||||
case 66:
|
||||
return 7;
|
||||
case 55:
|
||||
return 6;
|
||||
case 44:
|
||||
return 5;
|
||||
case 33:
|
||||
return 4;
|
||||
case 22:
|
||||
return 3;
|
||||
case 11:
|
||||
return 2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviewRankTier = (
|
||||
score: number,
|
||||
enableMagicNumber: boolean,
|
||||
playerMagicNumber: number | null,
|
||||
): number => {
|
||||
if (enableMagicNumber && playerMagicNumber !== null && score === playerMagicNumber) {
|
||||
return 9;
|
||||
}
|
||||
|
||||
if (score === 21) {
|
||||
return 8;
|
||||
}
|
||||
|
||||
const doubleTier = getDoubleRankTier(score);
|
||||
if (doubleTier !== null) {
|
||||
return doubleTier;
|
||||
}
|
||||
|
||||
if (score === 31) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
type PreviewTurn = Pick<TurnResult, 'playerId' | 'playerName' | 'score'>;
|
||||
|
||||
export function GameScreen({ navigation }: GameScreenProps) {
|
||||
const {
|
||||
phase,
|
||||
@@ -128,86 +83,16 @@ export function GameScreen({ navigation }: GameScreenProps) {
|
||||
const canReRoll = !isTieBreak && awaitingDecision && currentRollCount < maxRolls && !pendingRoll;
|
||||
const canStop = !isTieBreak && awaitingDecision && currentRollCount > 0 && !pendingRoll;
|
||||
|
||||
const provisionalLoser = useMemo<{ name: string; score: number } | null>(() => {
|
||||
if (roundSummaryReady && roundSummary) {
|
||||
const loserStanding = roundSummary.standings[roundSummary.standings.length - 1];
|
||||
if (!loserStanding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: loserStanding.playerName,
|
||||
score: loserStanding.score,
|
||||
};
|
||||
}
|
||||
|
||||
if (phase !== 'playing') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewTurns: PreviewTurn[] = turns.map((turn) => ({
|
||||
playerId: turn.playerId,
|
||||
playerName: turn.playerName,
|
||||
score: turn.score,
|
||||
}));
|
||||
|
||||
if (currentScore !== null && currentRollCount > 0 && currentPlayer) {
|
||||
const hasCurrentPlayerAlready = previewTurns.some(
|
||||
(turn) => turn.playerId === currentPlayer.id,
|
||||
);
|
||||
|
||||
if (!hasCurrentPlayerAlready) {
|
||||
previewTurns.push({
|
||||
playerId: currentPlayer.id,
|
||||
playerName: currentPlayer.name,
|
||||
score: currentScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (previewTurns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rankedPreview = previewTurns
|
||||
.map((turn) => {
|
||||
const playerMagicNumber =
|
||||
players.find((player) => player.id === turn.playerId)?.magicNumber ?? null;
|
||||
const rankTier = getPreviewRankTier(
|
||||
turn.score,
|
||||
settings.enableMagicNumber,
|
||||
playerMagicNumber,
|
||||
);
|
||||
const rankValue = rankTier === 0 ? -100 : turn.score;
|
||||
return { ...turn, rankTier, rankValue };
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.rankTier !== right.rankTier) {
|
||||
return right.rankTier - left.rankTier;
|
||||
}
|
||||
return right.rankValue - left.rankValue;
|
||||
});
|
||||
|
||||
const loserPreview = rankedPreview[rankedPreview.length - 1];
|
||||
if (!loserPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: loserPreview.playerName,
|
||||
score: loserPreview.score,
|
||||
};
|
||||
}, [
|
||||
currentPlayer,
|
||||
currentRollCount,
|
||||
currentScore,
|
||||
const provisionalLoser = useProvisionalLoser({
|
||||
phase,
|
||||
players,
|
||||
roundSummary,
|
||||
roundSummaryReady,
|
||||
settings.enableMagicNumber,
|
||||
players,
|
||||
turns,
|
||||
]);
|
||||
settings,
|
||||
currentScore,
|
||||
currentRollCount,
|
||||
currentPlayer,
|
||||
});
|
||||
|
||||
const nextPlayerName = useMemo(() => {
|
||||
if (!awaitingNextPlayer) {
|
||||
@@ -479,7 +364,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
|
||||
onPress={openRoundSummary}
|
||||
>
|
||||
<Text style={styles.actionLabel} numberOfLines={1} ellipsizeMode="tail">
|
||||
Voir l'écran de fin du tour
|
||||
Voir l’écran de fin du tour
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : awaitingNextPlayer ? (
|
||||
@@ -508,7 +393,7 @@ export function GameScreen({ navigation }: GameScreenProps) {
|
||||
|
||||
{canStop && (
|
||||
<Pressable style={styles.actionButtonAlt} onPress={stopCurrentTurn}>
|
||||
<Text style={styles.actionLabel}>S'arrêter</Text>
|
||||
<Text style={styles.actionLabel}>S’arrêter</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { buildLeaderboardRanking } from '../services/leaderboardService';
|
||||
import { useGameStore } from '../store';
|
||||
import { presets, textWeight, ui } from '../styles/ui';
|
||||
|
||||
@@ -13,15 +14,7 @@ export function LeaderboardScreen({ navigation }: LeaderboardScreenProps) {
|
||||
const resetAllTimePlayer = useGameStore((state) => state.resetAllTimePlayer);
|
||||
|
||||
const ranking = useMemo(() => {
|
||||
return Object.entries(allTimeLeaderboard)
|
||||
.map(([name, drinksConsumed]) => ({ name, drinksConsumed }))
|
||||
.sort((left, right) => {
|
||||
if (left.drinksConsumed !== right.drinksConsumed) {
|
||||
return right.drinksConsumed - left.drinksConsumed;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'fr');
|
||||
});
|
||||
return buildLeaderboardRanking(allTimeLeaderboard);
|
||||
}, [allTimeLeaderboard]);
|
||||
|
||||
const goBackToSetup = () => {
|
||||
|
||||
@@ -13,28 +13,12 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import { buildPotFormula, formatDrink } from '../services/scoreService';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { effects, presets, textWeight, ui } from '../styles/ui';
|
||||
|
||||
type ScoreScreenProps = NativeStackScreenProps<RootStackParamList, 'Score'>;
|
||||
|
||||
const toRoundedDrink = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
const isIntegerLike = (value: number): boolean => {
|
||||
return Math.abs(value - Math.round(value)) < 0.0000001;
|
||||
};
|
||||
|
||||
const formatDrink = (value: number): string => {
|
||||
const rounded = toRoundedDrink(value);
|
||||
if (Number.isInteger(rounded)) {
|
||||
return `${rounded}`;
|
||||
}
|
||||
|
||||
return rounded.toFixed(2).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
|
||||
};
|
||||
|
||||
const isFactorOne = (value: number): boolean => Math.abs(value - 1) < 0.0001;
|
||||
|
||||
export function ScoreScreen({ navigation }: ScoreScreenProps) {
|
||||
const { phase, roundSummary, players, startNextRound, resetGame } = useGameStore();
|
||||
const pulse = useSharedValue(1);
|
||||
@@ -75,23 +59,7 @@ export function ScoreScreen({ navigation }: ScoreScreenProps) {
|
||||
}, [players]);
|
||||
|
||||
const potFormula = useMemo(() => {
|
||||
if (!roundSummary || isFactorOne(roundSummary.appliedPotFactor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = formatDrink(roundSummary.basePotValue);
|
||||
const result = formatDrink(roundSummary.potValue);
|
||||
|
||||
if (roundSummary.appliedPotFactor < 1) {
|
||||
const divisorValue = 1 / roundSummary.appliedPotFactor;
|
||||
const divisor = formatDrink(divisorValue);
|
||||
const isExactDivision = isIntegerLike(roundSummary.basePotValue / divisorValue);
|
||||
const equalsSymbol = isExactDivision ? '=' : '≈';
|
||||
return `${base} / ${divisor} ${equalsSymbol} ${result}`;
|
||||
}
|
||||
|
||||
const multiplier = formatDrink(roundSummary.appliedPotFactor);
|
||||
return `${base} x ${multiplier} = ${result}`;
|
||||
return buildPotFormula(roundSummary);
|
||||
}, [roundSummary]);
|
||||
|
||||
if (!roundSummary) {
|
||||
|
||||
+45
-123
@@ -13,83 +13,23 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { RulesModal } from '../components/RulesModal';
|
||||
import { MIN_PLAYERS } from '../constants/game';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
buildEndRoundPotFactorOptions,
|
||||
END_ROUND_POT_FACTOR_CENTER_INDEX,
|
||||
END_ROUND_POT_FACTOR_ITEM_WIDTH,
|
||||
sanitizeNames,
|
||||
type EndRoundPotFactorOption,
|
||||
validateProfileInput,
|
||||
validateStartGameInput,
|
||||
} from '../services/setupService';
|
||||
import { useGameStore } from '../store';
|
||||
import type { GameSettings } from '../store';
|
||||
import { effects, presets, textWeight, ui } from '../styles/ui';
|
||||
|
||||
type SetupScreenProps = NativeStackScreenProps<RootStackParamList, 'Setup'>;
|
||||
|
||||
const MIN_PLAYERS = 2;
|
||||
|
||||
type EndRoundPotFactorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
const END_ROUND_POT_FACTOR_SIDE_ITEMS = 5000;
|
||||
const END_ROUND_POT_FACTOR_CENTER_INDEX = END_ROUND_POT_FACTOR_SIDE_ITEMS;
|
||||
const END_ROUND_POT_FACTOR_TOTAL_ITEMS = END_ROUND_POT_FACTOR_SIDE_ITEMS * 2 + 1;
|
||||
const END_ROUND_POT_FACTOR_ITEM_WIDTH = 64;
|
||||
|
||||
const buildEndRoundPotFactorOption = (step: number): EndRoundPotFactorOption => {
|
||||
if (step === 0) {
|
||||
return {
|
||||
id: 'x1',
|
||||
label: 'x1',
|
||||
value: 1,
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
if (step > 0) {
|
||||
const multiplier = step + 1;
|
||||
return {
|
||||
id: `x${multiplier}`,
|
||||
label: `x${multiplier}`,
|
||||
value: multiplier,
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
const divisor = Math.abs(step) + 1;
|
||||
return {
|
||||
id: `d${divisor}`,
|
||||
label: `/${divisor}`,
|
||||
value: 1 / divisor,
|
||||
step,
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeNames = (names: string[]): string[] => {
|
||||
return names.map((name) => name.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const parseMagic = (raw: string): number | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d{2}$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isInteger(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isValidMagicNumber = (value: number): boolean => {
|
||||
if (value < 11 || value > 66 || value === 31) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tens = Math.floor(value / 10);
|
||||
const units = value % 10;
|
||||
return tens >= units;
|
||||
};
|
||||
|
||||
export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
const setupGame = useGameStore((state) => state.setupGame);
|
||||
const savedProfiles = useGameStore((state) => state.savedProfiles);
|
||||
@@ -117,10 +57,7 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
const endRoundPotFactorListRef = useRef<FlatList<EndRoundPotFactorOption>>(null);
|
||||
|
||||
const endRoundPotFactorOptions = useMemo(
|
||||
() =>
|
||||
Array.from({ length: END_ROUND_POT_FACTOR_TOTAL_ITEMS }, (_, index) =>
|
||||
buildEndRoundPotFactorOption(index - END_ROUND_POT_FACTOR_CENTER_INDEX),
|
||||
),
|
||||
() => buildEndRoundPotFactorOptions(),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -207,14 +144,14 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
};
|
||||
|
||||
const saveNewProfile = () => {
|
||||
const cleanName = profileName.trim();
|
||||
if (!cleanName) {
|
||||
const profileValidation = validateProfileInput(profileName, profileMagicNumber);
|
||||
|
||||
if (!profileValidation.ok && profileValidation.reason === 'missing-name') {
|
||||
Alert.alert('Profil invalide', 'Le nom du profil est obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMagic = parseMagic(profileMagicNumber);
|
||||
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
|
||||
if (!profileValidation.ok && profileValidation.reason === 'invalid-magic') {
|
||||
Alert.alert(
|
||||
'Chiffre magique invalide',
|
||||
'Le profil doit avoir un chiffre magique valide entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
|
||||
@@ -222,7 +159,11 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveProfile(cleanName, parsedMagic);
|
||||
if (!profileValidation.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveProfile(profileValidation.cleanName, profileValidation.magicNumber);
|
||||
setProfileName('');
|
||||
setProfileMagicNumber('22');
|
||||
};
|
||||
@@ -233,56 +174,37 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
};
|
||||
|
||||
const startGame = () => {
|
||||
const playerEntries = playerNames
|
||||
.map((name, index) => ({
|
||||
name: name.trim(),
|
||||
magicInput: magicNumbersByPlayer[index] ?? '',
|
||||
}))
|
||||
.filter((entry) => entry.name.length > 0);
|
||||
const startValidation = validateStartGameInput({
|
||||
playerNames,
|
||||
magicNumbersByPlayer,
|
||||
enableMagicNumber,
|
||||
minPlayers: MIN_PLAYERS,
|
||||
});
|
||||
|
||||
const cleanNames = playerEntries.map((entry) => entry.name);
|
||||
if (cleanNames.length < MIN_PLAYERS) {
|
||||
if (!startValidation.ok && startValidation.reason === 'insufficient-players') {
|
||||
Alert.alert('Joueurs insuffisants', 'Ajoute au moins 2 joueurs.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableMagicNumber) {
|
||||
const playersWithMissingMagic = playerEntries
|
||||
.filter((entry) => entry.magicInput.trim().length === 0)
|
||||
.map((entry) => entry.name);
|
||||
|
||||
if (playersWithMissingMagic.length > 0) {
|
||||
const playersList = playersWithMissingMagic.join(', ');
|
||||
Alert.alert(
|
||||
'Chiffres magiques manquants',
|
||||
`Renseigne le chiffre magique avant de démarrer pour: ${playersList}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!startValidation.ok && startValidation.reason === 'missing-magic') {
|
||||
const playersList = (startValidation.players ?? []).join(', ');
|
||||
Alert.alert(
|
||||
'Chiffres magiques manquants',
|
||||
`Renseigne le chiffre magique avant de démarrer pour: ${playersList}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMagicNumbers = playerEntries.map((entry) => {
|
||||
if (!enableMagicNumber) {
|
||||
return null;
|
||||
}
|
||||
if (!startValidation.ok && startValidation.reason === 'invalid-magic') {
|
||||
Alert.alert(
|
||||
'Chiffre magique invalide',
|
||||
'Chaque joueur doit avoir 2 chiffres entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMagic = parseMagic(entry.magicInput);
|
||||
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedMagic;
|
||||
});
|
||||
|
||||
if (enableMagicNumber) {
|
||||
const hasInvalidEntry = parsedMagicNumbers.some((value) => value === null);
|
||||
if (hasInvalidEntry) {
|
||||
Alert.alert(
|
||||
'Chiffre magique invalide',
|
||||
'Chaque joueur doit avoir 2 chiffres entre 11 et 66, avec dizaine ≥ unité, et différent de 31.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!startValidation.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings: GameSettings = {
|
||||
@@ -293,8 +215,8 @@ export function SetupScreen({ navigation }: SetupScreenProps) {
|
||||
};
|
||||
|
||||
setupGame({
|
||||
playerNames: cleanNames,
|
||||
magicNumbers: parsedMagicNumbers,
|
||||
playerNames: startValidation.cleanNames,
|
||||
magicNumbers: startValidation.parsedMagicNumbers,
|
||||
settings,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const parseDicePayload = (payload: unknown): [number, number] | null => {
|
||||
if (!Array.isArray(payload) || payload.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = Number(payload[0]);
|
||||
const second = Number(payload[1]);
|
||||
|
||||
if (
|
||||
!Number.isInteger(first) ||
|
||||
!Number.isInteger(second) ||
|
||||
first < 1 ||
|
||||
first > 6 ||
|
||||
second < 1 ||
|
||||
second > 6
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [first, second];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export type LeaderboardEntry = {
|
||||
name: string;
|
||||
drinksConsumed: number;
|
||||
};
|
||||
|
||||
export const buildLeaderboardRanking = (
|
||||
allTimeLeaderboard: Record<string, number>,
|
||||
): LeaderboardEntry[] => {
|
||||
return Object.entries(allTimeLeaderboard)
|
||||
.map(([name, drinksConsumed]) => ({ name, drinksConsumed }))
|
||||
.sort((left, right) => {
|
||||
if (left.drinksConsumed !== right.drinksConsumed) {
|
||||
return right.drinksConsumed - left.drinksConsumed;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'fr');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
type ScoreEntry = {
|
||||
playerId: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type RankedEntry<T extends ScoreEntry> = T & {
|
||||
rankTier: number;
|
||||
rankValue: number;
|
||||
};
|
||||
|
||||
export type RankingOptions = {
|
||||
enableMagicNumber: boolean;
|
||||
magicNumberByPlayerId: Record<string, number | null | undefined>;
|
||||
};
|
||||
|
||||
export const getDoubleRankTier = (score: number): number | null => {
|
||||
switch (score) {
|
||||
case 66:
|
||||
return 7;
|
||||
case 55:
|
||||
return 6;
|
||||
case 44:
|
||||
return 5;
|
||||
case 33:
|
||||
return 4;
|
||||
case 22:
|
||||
return 3;
|
||||
case 11:
|
||||
return 2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRankTierForScore = (
|
||||
score: number,
|
||||
enableMagicNumber: boolean,
|
||||
playerMagicNumber: number | null | undefined,
|
||||
): number => {
|
||||
if (enableMagicNumber && playerMagicNumber !== null && playerMagicNumber !== undefined) {
|
||||
if (score === playerMagicNumber) {
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
|
||||
if (score === 21) {
|
||||
return 8;
|
||||
}
|
||||
|
||||
const doubleTier = getDoubleRankTier(score);
|
||||
if (doubleTier !== null) {
|
||||
return doubleTier;
|
||||
}
|
||||
|
||||
if (score === 31) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const getRankValue = (score: number, tier: number): number => {
|
||||
if (tier === 0) {
|
||||
return -100;
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
export const compareRankedEntries = <T extends ScoreEntry>(
|
||||
left: RankedEntry<T>,
|
||||
right: RankedEntry<T>,
|
||||
): number => {
|
||||
if (left.rankTier !== right.rankTier) {
|
||||
return right.rankTier - left.rankTier;
|
||||
}
|
||||
|
||||
return right.rankValue - left.rankValue;
|
||||
};
|
||||
|
||||
export const rankEntries = <T extends ScoreEntry>(
|
||||
entries: T[],
|
||||
options: RankingOptions,
|
||||
): RankedEntry<T>[] => {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const playerMagicNumber = options.magicNumberByPlayerId[entry.playerId] ?? null;
|
||||
const rankTier = getRankTierForScore(
|
||||
entry.score,
|
||||
options.enableMagicNumber,
|
||||
playerMagicNumber,
|
||||
);
|
||||
const rankValue = getRankValue(entry.score, rankTier);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
rankTier,
|
||||
rankValue,
|
||||
};
|
||||
})
|
||||
.sort(compareRankedEntries);
|
||||
};
|
||||
|
||||
export const getLowestRankedPlayerIds = (
|
||||
standings: { playerId: string; rankTier: number; rankValue: number }[],
|
||||
): string[] => {
|
||||
if (standings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowest = standings[standings.length - 1];
|
||||
|
||||
return standings
|
||||
.filter((entry) => entry.rankTier === lowest.rankTier && entry.rankValue === lowest.rankValue)
|
||||
.map((entry) => entry.playerId);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RoundSummary } from '../store/gameStore';
|
||||
|
||||
export const toRoundedDrink = (value: number): number => {
|
||||
return Math.round(value * 100) / 100;
|
||||
};
|
||||
|
||||
export const isIntegerLike = (value: number): boolean => {
|
||||
return Math.abs(value - Math.round(value)) < 0.0000001;
|
||||
};
|
||||
|
||||
export const formatDrink = (value: number): string => {
|
||||
const rounded = toRoundedDrink(value);
|
||||
if (Number.isInteger(rounded)) {
|
||||
return `${rounded}`;
|
||||
}
|
||||
|
||||
return rounded.toFixed(2).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
|
||||
};
|
||||
|
||||
export const isFactorOne = (value: number): boolean => {
|
||||
return Math.abs(value - 1) < 0.0001;
|
||||
};
|
||||
|
||||
export const buildPotFormula = (roundSummary: RoundSummary | null): string | null => {
|
||||
if (!roundSummary || isFactorOne(roundSummary.appliedPotFactor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = formatDrink(roundSummary.basePotValue);
|
||||
const result = formatDrink(roundSummary.potValue);
|
||||
|
||||
if (roundSummary.appliedPotFactor < 1) {
|
||||
const divisorValue = 1 / roundSummary.appliedPotFactor;
|
||||
const divisor = formatDrink(divisorValue);
|
||||
const isExactDivision = isIntegerLike(roundSummary.basePotValue / divisorValue);
|
||||
const equalsSymbol = isExactDivision ? '=' : '≈';
|
||||
|
||||
return `${base} / ${divisor} ${equalsSymbol} ${result}`;
|
||||
}
|
||||
|
||||
const multiplier = formatDrink(roundSummary.appliedPotFactor);
|
||||
return `${base} x ${multiplier} = ${result}`;
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
MAGIC_NUMBER_FORBIDDEN,
|
||||
MAGIC_NUMBER_MAX,
|
||||
MAGIC_NUMBER_MIN,
|
||||
MIN_PLAYERS,
|
||||
} from '../constants/game';
|
||||
|
||||
export type EndRoundPotFactorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
export const END_ROUND_POT_FACTOR_SIDE_ITEMS = 5000;
|
||||
export const END_ROUND_POT_FACTOR_CENTER_INDEX = END_ROUND_POT_FACTOR_SIDE_ITEMS;
|
||||
export const END_ROUND_POT_FACTOR_TOTAL_ITEMS = END_ROUND_POT_FACTOR_SIDE_ITEMS * 2 + 1;
|
||||
export const END_ROUND_POT_FACTOR_ITEM_WIDTH = 64;
|
||||
|
||||
export const buildEndRoundPotFactorOption = (step: number): EndRoundPotFactorOption => {
|
||||
if (step === 0) {
|
||||
return {
|
||||
id: 'x1',
|
||||
label: 'x1',
|
||||
value: 1,
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
if (step > 0) {
|
||||
const multiplier = step + 1;
|
||||
return {
|
||||
id: `x${multiplier}`,
|
||||
label: `x${multiplier}`,
|
||||
value: multiplier,
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
const divisor = Math.abs(step) + 1;
|
||||
|
||||
return {
|
||||
id: `d${divisor}`,
|
||||
label: `/${divisor}`,
|
||||
value: 1 / divisor,
|
||||
step,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEndRoundPotFactorOptions = (): EndRoundPotFactorOption[] => {
|
||||
return Array.from({ length: END_ROUND_POT_FACTOR_TOTAL_ITEMS }, (_, index) =>
|
||||
buildEndRoundPotFactorOption(index - END_ROUND_POT_FACTOR_CENTER_INDEX),
|
||||
);
|
||||
};
|
||||
|
||||
export const sanitizeNames = (names: string[]): string[] => {
|
||||
return names.map((name) => name.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseMagicNumber = (raw: string): number | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d{2}$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isInteger(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const isValidMagicNumber = (value: number): boolean => {
|
||||
if (value < MAGIC_NUMBER_MIN || value > MAGIC_NUMBER_MAX || value === MAGIC_NUMBER_FORBIDDEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tens = Math.floor(value / 10);
|
||||
const units = value % 10;
|
||||
|
||||
return tens >= units;
|
||||
};
|
||||
|
||||
type StartGameValidationInput = {
|
||||
playerNames: string[];
|
||||
magicNumbersByPlayer: string[];
|
||||
enableMagicNumber: boolean;
|
||||
minPlayers?: number;
|
||||
};
|
||||
|
||||
type StartGameValidationSuccess = {
|
||||
ok: true;
|
||||
cleanNames: string[];
|
||||
parsedMagicNumbers: (number | null)[];
|
||||
};
|
||||
|
||||
type StartGameValidationFailure = {
|
||||
ok: false;
|
||||
reason: 'insufficient-players' | 'missing-magic' | 'invalid-magic';
|
||||
players?: string[];
|
||||
};
|
||||
|
||||
export type StartGameValidationResult =
|
||||
| StartGameValidationSuccess
|
||||
| StartGameValidationFailure;
|
||||
|
||||
export const validateStartGameInput = ({
|
||||
playerNames,
|
||||
magicNumbersByPlayer,
|
||||
enableMagicNumber,
|
||||
minPlayers = MIN_PLAYERS,
|
||||
}: StartGameValidationInput): StartGameValidationResult => {
|
||||
const playerEntries = playerNames
|
||||
.map((name, index) => ({
|
||||
name: name.trim(),
|
||||
magicInput: magicNumbersByPlayer[index] ?? '',
|
||||
}))
|
||||
.filter((entry) => entry.name.length > 0);
|
||||
|
||||
const cleanNames = playerEntries.map((entry) => entry.name);
|
||||
|
||||
if (cleanNames.length < minPlayers) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'insufficient-players',
|
||||
};
|
||||
}
|
||||
|
||||
if (enableMagicNumber) {
|
||||
const playersWithMissingMagic = playerEntries
|
||||
.filter((entry) => entry.magicInput.trim().length === 0)
|
||||
.map((entry) => entry.name);
|
||||
|
||||
if (playersWithMissingMagic.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-magic',
|
||||
players: playersWithMissingMagic,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parsedMagicNumbers = playerEntries.map((entry) => {
|
||||
if (!enableMagicNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedMagic = parseMagicNumber(entry.magicInput);
|
||||
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedMagic;
|
||||
});
|
||||
|
||||
if (enableMagicNumber) {
|
||||
const hasInvalidEntry = parsedMagicNumbers.some((value) => value === null);
|
||||
if (hasInvalidEntry) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-magic',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cleanNames,
|
||||
parsedMagicNumbers,
|
||||
};
|
||||
};
|
||||
|
||||
type ProfileValidationSuccess = {
|
||||
ok: true;
|
||||
cleanName: string;
|
||||
magicNumber: number;
|
||||
};
|
||||
|
||||
type ProfileValidationFailure = {
|
||||
ok: false;
|
||||
reason: 'missing-name' | 'invalid-magic';
|
||||
};
|
||||
|
||||
export type ProfileValidationResult = ProfileValidationSuccess | ProfileValidationFailure;
|
||||
|
||||
export const validateProfileInput = (
|
||||
profileName: string,
|
||||
rawMagicNumber: string,
|
||||
): ProfileValidationResult => {
|
||||
const cleanName = profileName.trim();
|
||||
if (!cleanName) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-name',
|
||||
};
|
||||
}
|
||||
|
||||
const parsedMagic = parseMagicNumber(rawMagicNumber);
|
||||
if (parsedMagic === null || !isValidMagicNumber(parsedMagic)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid-magic',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cleanName,
|
||||
magicNumber: parsedMagic,
|
||||
};
|
||||
};
|
||||
+15
-80
@@ -1,6 +1,7 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware';
|
||||
import { getLowestRankedPlayerIds, rankEntries } from '../services/rankingService';
|
||||
import { getRollProbability } from '../utils/probability';
|
||||
|
||||
export type DicePair = [number, number];
|
||||
@@ -104,7 +105,7 @@ export type SavedProfile = {
|
||||
|
||||
type SetupPayload = {
|
||||
playerNames: string[];
|
||||
magicNumbers: Array<number | null>;
|
||||
magicNumbers: (number | null)[];
|
||||
settings: GameSettings;
|
||||
};
|
||||
|
||||
@@ -254,93 +255,27 @@ const isDoubleScore = (score: number): boolean => {
|
||||
return tens === units;
|
||||
};
|
||||
|
||||
const getDoubleRankTier = (score: number): number | null => {
|
||||
switch (score) {
|
||||
case 66:
|
||||
return 7;
|
||||
case 55:
|
||||
return 6;
|
||||
case 44:
|
||||
return 5;
|
||||
case 33:
|
||||
return 4;
|
||||
case 22:
|
||||
return 3;
|
||||
case 11:
|
||||
return 2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getRankTier = (
|
||||
turn: TurnResult,
|
||||
settings: GameSettings,
|
||||
playerMagicNumber: number | null,
|
||||
): number => {
|
||||
if (
|
||||
settings.enableMagicNumber &&
|
||||
playerMagicNumber !== null &&
|
||||
turn.score === playerMagicNumber
|
||||
) {
|
||||
return 9;
|
||||
}
|
||||
|
||||
if (turn.score === 21) {
|
||||
return 8;
|
||||
}
|
||||
|
||||
const doubleTier = getDoubleRankTier(turn.score);
|
||||
if (doubleTier !== null) {
|
||||
return doubleTier;
|
||||
}
|
||||
|
||||
if (turn.score === 31) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const getRankValue = (turn: TurnResult, tier: number): number => {
|
||||
if (tier === 0) {
|
||||
return -100;
|
||||
}
|
||||
return turn.score;
|
||||
};
|
||||
|
||||
const compareRankedTurns = (left: RankedTurn, right: RankedTurn): number => {
|
||||
if (left.rankTier !== right.rankTier) {
|
||||
return right.rankTier - left.rankTier;
|
||||
}
|
||||
return right.rankValue - left.rankValue;
|
||||
};
|
||||
|
||||
const buildStandings = (
|
||||
turns: TurnResult[],
|
||||
players: Player[],
|
||||
settings: GameSettings,
|
||||
): RankedTurn[] => {
|
||||
return turns
|
||||
.map((turn) => {
|
||||
const playerMagicNumber =
|
||||
players.find((player) => player.id === turn.playerId)?.magicNumber ?? null;
|
||||
const rankTier = getRankTier(turn, settings, playerMagicNumber);
|
||||
const rankValue = getRankValue(turn, rankTier);
|
||||
return { ...turn, rankTier, rankValue };
|
||||
})
|
||||
.sort(compareRankedTurns);
|
||||
const magicNumberByPlayerId = players.reduce<Record<string, number | null>>(
|
||||
(accumulator, player) => {
|
||||
accumulator[player.id] = player.magicNumber;
|
||||
return accumulator;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return rankEntries(turns, {
|
||||
enableMagicNumber: settings.enableMagicNumber,
|
||||
magicNumberByPlayerId,
|
||||
}) as RankedTurn[];
|
||||
};
|
||||
|
||||
const getLowestTiePlayerIds = (standings: RankedTurn[]): string[] => {
|
||||
if (standings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowest = standings[standings.length - 1];
|
||||
return standings
|
||||
.filter((entry) => entry.rankTier === lowest.rankTier && entry.rankValue === lowest.rankValue)
|
||||
.map((entry) => entry.playerId);
|
||||
return getLowestRankedPlayerIds(standings);
|
||||
};
|
||||
|
||||
const getInitialState = (): Omit<
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { Text } from 'react-native';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { AppErrorBoundary } from '../../src/components/AppErrorBoundary';
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = () => undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
describe('AppErrorBoundary', () => {
|
||||
it('renders children when no error happens', () => {
|
||||
let tree = create(<></>);
|
||||
|
||||
act(() => {
|
||||
tree = create(
|
||||
<AppErrorBoundary>
|
||||
<Text>OK</Text>
|
||||
</AppErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(JSON.stringify(tree.toJSON())).toContain('OK');
|
||||
});
|
||||
|
||||
it('derives error state from runtime exceptions', () => {
|
||||
expect(AppErrorBoundary.getDerivedStateFromError()).toEqual({
|
||||
hasError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { Pressable } from 'react-native';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { PlayerAvatar } from '../../src/components/PlayerAvatar';
|
||||
|
||||
describe('PlayerAvatar', () => {
|
||||
it('renders primary player details', () => {
|
||||
let tree = create(<></>);
|
||||
|
||||
act(() => {
|
||||
tree = create(
|
||||
<PlayerAvatar
|
||||
name="Alice"
|
||||
drinks={3}
|
||||
lastScore={54}
|
||||
roundScore={54}
|
||||
rollsUsed={2}
|
||||
badgesText="21 (+10 pot)"
|
||||
rank={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const snapshot = JSON.stringify(tree.toJSON());
|
||||
expect(snapshot).toContain('Alice');
|
||||
expect(snapshot).toContain('🍻 ');
|
||||
expect(snapshot).toContain('gorgées');
|
||||
expect(snapshot).toContain('🎯 ');
|
||||
expect(snapshot).toContain('21 (+10 pot)');
|
||||
});
|
||||
|
||||
it('calls reset callback when reset button is pressed', () => {
|
||||
const onReset = mock(() => { });
|
||||
let tree = create(<></>);
|
||||
|
||||
act(() => {
|
||||
tree = create(
|
||||
<PlayerAvatar
|
||||
name="Alice"
|
||||
drinks={0}
|
||||
lastScore={null}
|
||||
onReset={onReset}
|
||||
showReset
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const resetButton = tree.root.findByType(Pressable);
|
||||
|
||||
act(() => {
|
||||
resetButton.props.onPress();
|
||||
});
|
||||
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not render reset button when disabled', () => {
|
||||
let tree = create(<></>);
|
||||
|
||||
act(() => {
|
||||
tree = create(
|
||||
<PlayerAvatar
|
||||
name="Alice"
|
||||
drinks={0}
|
||||
lastScore={null}
|
||||
showReset={false}
|
||||
onReset={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(tree.root.findAllByType(Pressable)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import {
|
||||
computeProvisionalLoser,
|
||||
useProvisionalLoser,
|
||||
} from '../../src/hooks/useProvisionalLoser';
|
||||
import type { GameSettings, Player, RoundSummary, TurnResult } from '../../src/store/gameStore';
|
||||
|
||||
const settings: GameSettings = {
|
||||
enableShake: true,
|
||||
enableRub: true,
|
||||
enableMagicNumber: false,
|
||||
endRoundPotFactor: 1,
|
||||
};
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Alice',
|
||||
magicNumber: null,
|
||||
drinksConsumed: 0,
|
||||
lastScore: null,
|
||||
lastDice: null,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'Bob',
|
||||
magicNumber: null,
|
||||
drinksConsumed: 0,
|
||||
lastScore: null,
|
||||
lastDice: null,
|
||||
},
|
||||
];
|
||||
|
||||
const turns: TurnResult[] = [
|
||||
{
|
||||
playerId: 'a',
|
||||
playerName: 'Alice',
|
||||
score: 54,
|
||||
dice: [5, 4],
|
||||
rollsUsed: 1,
|
||||
probability: 2 / 36,
|
||||
badges: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('useProvisionalLoser', () => {
|
||||
it('computes provisional loser from preview turns in playing phase', () => {
|
||||
const loser = computeProvisionalLoser({
|
||||
phase: 'playing',
|
||||
roundSummary: null,
|
||||
players,
|
||||
turns,
|
||||
settings,
|
||||
currentScore: 31,
|
||||
currentRollCount: 1,
|
||||
currentPlayer: players[1],
|
||||
});
|
||||
|
||||
expect(loser).toEqual({ name: 'Bob', score: 31 });
|
||||
});
|
||||
|
||||
it('prefers round summary standings when available', () => {
|
||||
const roundSummary: RoundSummary = {
|
||||
winnerName: 'Alice',
|
||||
loserName: 'Bob',
|
||||
basePotValue: 2,
|
||||
potValue: 2,
|
||||
appliedPotFactor: 1,
|
||||
endedBy31: false,
|
||||
tieBreakRounds: 0,
|
||||
standings: [
|
||||
{
|
||||
...turns[0],
|
||||
rankTier: 1,
|
||||
rankValue: 54,
|
||||
},
|
||||
{
|
||||
playerId: 'b',
|
||||
playerName: 'Bob',
|
||||
score: 31,
|
||||
dice: [3, 1],
|
||||
rollsUsed: 1,
|
||||
probability: 2 / 36,
|
||||
badges: [],
|
||||
rankTier: 0,
|
||||
rankValue: -100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const loser = computeProvisionalLoser({
|
||||
phase: 'round-summary',
|
||||
roundSummary,
|
||||
players,
|
||||
turns,
|
||||
settings,
|
||||
currentScore: null,
|
||||
currentRollCount: 0,
|
||||
currentPlayer: undefined,
|
||||
});
|
||||
|
||||
expect(loser).toEqual({ name: 'Bob', score: 31 });
|
||||
});
|
||||
|
||||
it('works as a hook inside a component tree', () => {
|
||||
let observed: { name: string; score: number } | null = null;
|
||||
|
||||
const Probe = () => {
|
||||
observed = useProvisionalLoser({
|
||||
phase: 'playing',
|
||||
roundSummary: null,
|
||||
players,
|
||||
turns,
|
||||
settings,
|
||||
currentScore: 31,
|
||||
currentRollCount: 1,
|
||||
currentPlayer: players[1],
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
create(<Probe />);
|
||||
});
|
||||
|
||||
expect(observed).not.toBeNull();
|
||||
expect(observed).toMatchObject({ name: 'Bob', score: 31 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { mock } from 'bun:test';
|
||||
|
||||
const createPrimitive = (name: string) => {
|
||||
const Primitive = ({ children, ...props }: Record<string, unknown>) => {
|
||||
const resolvedChildren =
|
||||
typeof children === 'function' ? (children as (state: { pressed: boolean }) => unknown)({ pressed: false }) : children;
|
||||
|
||||
return React.createElement(name, props, resolvedChildren as React.ReactNode);
|
||||
};
|
||||
|
||||
Primitive.displayName = name;
|
||||
return Primitive;
|
||||
};
|
||||
|
||||
const View = createPrimitive('View');
|
||||
const Text = createPrimitive('Text');
|
||||
const ScrollView = createPrimitive('ScrollView');
|
||||
const ActivityIndicator = createPrimitive('ActivityIndicator');
|
||||
|
||||
const Pressable = ({ children, ...props }: Record<string, unknown>) => {
|
||||
const resolvedChildren =
|
||||
typeof children === 'function' ? (children as (state: { pressed: boolean }) => unknown)({ pressed: false }) : children;
|
||||
|
||||
return React.createElement('Pressable', props, resolvedChildren as React.ReactNode);
|
||||
};
|
||||
|
||||
const Modal = ({ visible, children, ...props }: Record<string, unknown>) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.createElement('Modal', props, children as React.ReactNode);
|
||||
};
|
||||
|
||||
mock.module('react-native', () => ({
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
StyleSheet: {
|
||||
create: (styles: Record<string, unknown>) => styles,
|
||||
flatten: (style: unknown) => style,
|
||||
absoluteFillObject: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
},
|
||||
Platform: {
|
||||
OS: 'android',
|
||||
select: (value: Record<string, unknown>) => value.android ?? value.default,
|
||||
},
|
||||
ToastAndroid: {
|
||||
SHORT: 0,
|
||||
LONG: 1,
|
||||
show: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: createPrimitive('SafeAreaView'),
|
||||
SafeAreaProvider: createPrimitive('SafeAreaProvider'),
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { parseDicePayload } from '../../src/services/dicePayloadService';
|
||||
|
||||
describe('dicePayloadService', () => {
|
||||
it('accepts valid dice payload', () => {
|
||||
expect(parseDicePayload([1, 6])).toEqual([1, 6]);
|
||||
expect(parseDicePayload(['2', 3])).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('rejects invalid payloads', () => {
|
||||
expect(parseDicePayload([])).toBeNull();
|
||||
expect(parseDicePayload([1])).toBeNull();
|
||||
expect(parseDicePayload([0, 6])).toBeNull();
|
||||
expect(parseDicePayload([1, 7])).toBeNull();
|
||||
expect(parseDicePayload([1.4, 2])).toBeNull();
|
||||
expect(parseDicePayload('1,2')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildLeaderboardRanking } from '../../src/services/leaderboardService';
|
||||
|
||||
describe('leaderboardService', () => {
|
||||
it('sorts by drinks descending then name ascending', () => {
|
||||
const ranking = buildLeaderboardRanking({
|
||||
Bob: 6,
|
||||
Alice: 6,
|
||||
Charlie: 2,
|
||||
});
|
||||
|
||||
expect(ranking).toEqual([
|
||||
{ name: 'Alice', drinksConsumed: 6 },
|
||||
{ name: 'Bob', drinksConsumed: 6 },
|
||||
{ name: 'Charlie', drinksConsumed: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getDoubleRankTier,
|
||||
getLowestRankedPlayerIds,
|
||||
getRankTierForScore,
|
||||
rankEntries,
|
||||
} from '../../src/services/rankingService';
|
||||
|
||||
describe('rankingService', () => {
|
||||
it('maps double scores to their rank tier', () => {
|
||||
expect(getDoubleRankTier(66)).toBe(7);
|
||||
expect(getDoubleRankTier(55)).toBe(6);
|
||||
expect(getDoubleRankTier(44)).toBe(5);
|
||||
expect(getDoubleRankTier(11)).toBe(2);
|
||||
expect(getDoubleRankTier(54)).toBeNull();
|
||||
});
|
||||
|
||||
it('applies score priorities including magic number and 31', () => {
|
||||
expect(getRankTierForScore(54, true, 54)).toBe(9);
|
||||
expect(getRankTierForScore(21, false, null)).toBe(8);
|
||||
expect(getRankTierForScore(66, false, null)).toBe(7);
|
||||
expect(getRankTierForScore(31, false, null)).toBe(0);
|
||||
expect(getRankTierForScore(54, false, null)).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts ranked entries by tier then value descending', () => {
|
||||
const standings = rankEntries(
|
||||
[
|
||||
{ playerId: 'a', score: 31 },
|
||||
{ playerId: 'b', score: 54 },
|
||||
{ playerId: 'c', score: 21 },
|
||||
{ playerId: 'd', score: 66 },
|
||||
],
|
||||
{
|
||||
enableMagicNumber: true,
|
||||
magicNumberByPlayerId: {
|
||||
a: null,
|
||||
b: 54,
|
||||
c: null,
|
||||
d: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(standings.map((entry) => entry.playerId)).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
it('returns all tied lowest player ids', () => {
|
||||
const lowest = getLowestRankedPlayerIds([
|
||||
{ playerId: 'winner', rankTier: 8, rankValue: 21 },
|
||||
{ playerId: 'tie-a', rankTier: 1, rankValue: 41 },
|
||||
{ playerId: 'tie-b', rankTier: 1, rankValue: 41 },
|
||||
]);
|
||||
|
||||
expect(lowest).toEqual(['tie-a', 'tie-b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildPotFormula, formatDrink, isFactorOne } from '../../src/services/scoreService';
|
||||
|
||||
describe('scoreService', () => {
|
||||
it('formats drink values with trimming', () => {
|
||||
expect(formatDrink(2)).toBe('2');
|
||||
expect(formatDrink(2.5)).toBe('2.5');
|
||||
expect(formatDrink(2.333)).toBe('2.33');
|
||||
});
|
||||
|
||||
it('detects factor equal to one with tolerance', () => {
|
||||
expect(isFactorOne(1)).toBe(true);
|
||||
expect(isFactorOne(1.00001)).toBe(true);
|
||||
expect(isFactorOne(1.01)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null formula when factor is one or summary is absent', () => {
|
||||
expect(buildPotFormula(null)).toBeNull();
|
||||
expect(
|
||||
buildPotFormula({
|
||||
winnerName: 'Alice',
|
||||
loserName: 'Bob',
|
||||
basePotValue: 5,
|
||||
potValue: 5,
|
||||
appliedPotFactor: 1,
|
||||
endedBy31: false,
|
||||
tieBreakRounds: 0,
|
||||
standings: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds multiplication formula when factor is greater than one', () => {
|
||||
expect(
|
||||
buildPotFormula({
|
||||
winnerName: 'Alice',
|
||||
loserName: 'Bob',
|
||||
basePotValue: 5,
|
||||
potValue: 10,
|
||||
appliedPotFactor: 2,
|
||||
endedBy31: false,
|
||||
tieBreakRounds: 0,
|
||||
standings: [],
|
||||
}),
|
||||
).toBe('5 x 2 = 10');
|
||||
});
|
||||
|
||||
it('builds division formula with exact and approximate symbols', () => {
|
||||
expect(
|
||||
buildPotFormula({
|
||||
winnerName: 'Alice',
|
||||
loserName: 'Bob',
|
||||
basePotValue: 6,
|
||||
potValue: 2,
|
||||
appliedPotFactor: 1 / 3,
|
||||
endedBy31: false,
|
||||
tieBreakRounds: 0,
|
||||
standings: [],
|
||||
}),
|
||||
).toBe('6 / 3 = 2');
|
||||
|
||||
expect(
|
||||
buildPotFormula({
|
||||
winnerName: 'Alice',
|
||||
loserName: 'Bob',
|
||||
basePotValue: 5,
|
||||
potValue: 1.67,
|
||||
appliedPotFactor: 1 / 3,
|
||||
endedBy31: false,
|
||||
tieBreakRounds: 0,
|
||||
standings: [],
|
||||
}),
|
||||
).toBe('5 / 3 ≈ 1.67');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { MIN_PLAYERS } from '../../src/constants/game';
|
||||
import {
|
||||
buildEndRoundPotFactorOption,
|
||||
isValidMagicNumber,
|
||||
parseMagicNumber,
|
||||
sanitizeNames,
|
||||
validateProfileInput,
|
||||
validateStartGameInput,
|
||||
} from '../../src/services/setupService';
|
||||
|
||||
describe('setupService', () => {
|
||||
it('builds end round pot factor options correctly', () => {
|
||||
expect(buildEndRoundPotFactorOption(0)).toEqual({
|
||||
id: 'x1',
|
||||
label: 'x1',
|
||||
value: 1,
|
||||
step: 0,
|
||||
});
|
||||
|
||||
expect(buildEndRoundPotFactorOption(2)).toEqual({
|
||||
id: 'x3',
|
||||
label: 'x3',
|
||||
value: 3,
|
||||
step: 2,
|
||||
});
|
||||
|
||||
expect(buildEndRoundPotFactorOption(-1)).toEqual({
|
||||
id: 'd2',
|
||||
label: '/2',
|
||||
value: 0.5,
|
||||
step: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes player names', () => {
|
||||
expect(sanitizeNames([' Alice ', '', ' Bob ', ' '])).toEqual(['Alice', 'Bob']);
|
||||
});
|
||||
|
||||
it('parses and validates magic numbers', () => {
|
||||
expect(parseMagicNumber('22')).toBe(22);
|
||||
expect(parseMagicNumber('2')).toBeNull();
|
||||
expect(parseMagicNumber('ab')).toBeNull();
|
||||
|
||||
expect(isValidMagicNumber(22)).toBe(true);
|
||||
expect(isValidMagicNumber(31)).toBe(false);
|
||||
expect(isValidMagicNumber(12)).toBe(false);
|
||||
expect(isValidMagicNumber(70)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates profile input', () => {
|
||||
expect(validateProfileInput(' ', '22')).toEqual({ ok: false, reason: 'missing-name' });
|
||||
expect(validateProfileInput('Alice', '31')).toEqual({ ok: false, reason: 'invalid-magic' });
|
||||
expect(validateProfileInput('Alice', '66')).toEqual({
|
||||
ok: true,
|
||||
cleanName: 'Alice',
|
||||
magicNumber: 66,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects start when not enough players', () => {
|
||||
const result = validateStartGameInput({
|
||||
playerNames: ['Alice', ' '],
|
||||
magicNumbersByPlayer: ['', ''],
|
||||
enableMagicNumber: false,
|
||||
minPlayers: MIN_PLAYERS,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'insufficient-players' });
|
||||
});
|
||||
|
||||
it('rejects start when magic numbers are missing', () => {
|
||||
const result = validateStartGameInput({
|
||||
playerNames: ['Alice', 'Bob'],
|
||||
magicNumbersByPlayer: ['', '22'],
|
||||
enableMagicNumber: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing-magic',
|
||||
players: ['Alice'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects start when at least one magic number is invalid', () => {
|
||||
const result = validateStartGameInput({
|
||||
playerNames: ['Alice', 'Bob'],
|
||||
magicNumbersByPlayer: ['66', '31'],
|
||||
enableMagicNumber: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'invalid-magic' });
|
||||
});
|
||||
|
||||
it('accepts valid start payload with enabled magic numbers', () => {
|
||||
const result = validateStartGameInput({
|
||||
playerNames: ['Alice', ' Bob '],
|
||||
magicNumbersByPlayer: ['66', '22'],
|
||||
enableMagicNumber: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
cleanNames: ['Alice', 'Bob'],
|
||||
parsedMagicNumbers: [66, 22],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts valid start payload with disabled magic numbers', () => {
|
||||
const result = validateStartGameInput({
|
||||
playerNames: ['Alice', 'Bob'],
|
||||
magicNumbersByPlayer: ['66', '22'],
|
||||
enableMagicNumber: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
cleanNames: ['Alice', 'Bob'],
|
||||
parsedMagicNumbers: [null, null],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getRareToastMessage,
|
||||
getRollProbability,
|
||||
isVeryRareRoll,
|
||||
} from '../../src/utils/probability';
|
||||
|
||||
const originalRandom = Math.random;
|
||||
|
||||
afterEach(() => {
|
||||
Math.random = originalRandom;
|
||||
});
|
||||
|
||||
describe('probability utils', () => {
|
||||
it('returns the correct roll probability', () => {
|
||||
expect(getRollProbability([1, 1])).toBe(1 / 36);
|
||||
expect(getRollProbability([6, 5])).toBe(2 / 36);
|
||||
});
|
||||
|
||||
it('identifies very rare rolls at threshold', () => {
|
||||
expect(isVeryRareRoll(1 / 36)).toBe(true);
|
||||
expect(isVeryRareRoll(2 / 36)).toBe(false);
|
||||
});
|
||||
|
||||
it('formats a rare toast message with percentage', () => {
|
||||
Math.random = () => 0;
|
||||
const message = getRareToastMessage(1 / 36);
|
||||
|
||||
expect(message).toContain('2.8%');
|
||||
expect(message).toContain('chance');
|
||||
});
|
||||
});
|
||||
+8
-5
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"types": [
|
||||
"bun-types"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user