diff --git a/app/index.tsx b/app/index.tsx index 775134e..e80c05c 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -6,9 +6,17 @@ import ChipsSelector from "@/components/ChipsSelector"; import ChipDistributionSummary from "@/components/ChipDistributionSummary"; import ChipDetection from "@/components/ChipDetection"; +export enum COLORS { + "white", + "red", + "green", + "blue", + "black", +} + const IndexScreen: React.FC = () => { const [playerCount, setPlayerCount] = useState(2); - const [buyInAmount, setBuyInAmount] = useState(null); + const [buyInAmount, setBuyInAmount] = useState(20); const [numberOfChips, setNumberOfChips] = useState(5); const [totalChipsCount, setTotalChipsCount] = useState([]); diff --git a/components/BuyInSelector.tsx b/components/BuyInSelector.tsx index 48d56c1..c986d32 100644 --- a/components/BuyInSelector.tsx +++ b/components/BuyInSelector.tsx @@ -9,7 +9,7 @@ import { import { MaterialIcons } from "@expo/vector-icons"; interface BuyInSelectorProps { - setBuyInAmount: React.Dispatch>; + setBuyInAmount: React.Dispatch>; } const defaultBuyInOptions = [10, 25, 50]; @@ -26,8 +26,8 @@ const BuyInSelector: React.FC = ({ setBuyInAmount }) => { setBuyInAmount(numericValue); } else { setCustomAmount(""); - setBuyInAmountState(null); - setBuyInAmount(null); + setBuyInAmountState(25); + setBuyInAmount(25); } }; diff --git a/components/ChipDistributionSummary.tsx b/components/ChipDistributionSummary.tsx index d7af770..c111dd4 100644 --- a/components/ChipDistributionSummary.tsx +++ b/components/ChipDistributionSummary.tsx @@ -1,65 +1,198 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { View, Text, StyleSheet } from "react-native"; import { ColorValue } from "react-native"; interface ChipDistributionSummaryProps { playerCount: number; - buyInAmount: number | null; + buyInAmount: number; totalChipsCount: number[]; colors?: ColorValue[]; } -const MAX_CHIPS = 500; - const ChipDistributionSummary = ({ playerCount, buyInAmount, totalChipsCount, colors = ["white", "red", "green", "blue", "black"], }: ChipDistributionSummaryProps) => { - const [chipDistribution, setChipDistribution] = useState([]); + const validDenominations: validDenomination[] = [ + 0.05, 0.1, 0.25, 0.5, 1, 2, 2.5, 5, 10, 20, 50, 100, + ]; + const [denominations, setDenominations] = useState([]); + const [distributions, setDistributions] = useState([]); - useEffect(() => { - if (buyInAmount !== null && playerCount > 0) { - let totalChips = totalChipsCount.reduce((sum, count) => sum + count, 0); + type validDenomination = + | 0.05 + | 0.1 + | 0.25 + | 0.5 + | 1 + | 2 + | 2.5 + | 5 + | 10 + | 20 + | 50 + | 100; - if (totalChips > MAX_CHIPS) { - const scaleFactor = MAX_CHIPS / totalChips; - totalChipsCount = totalChipsCount.map((count) => - Math.floor(count * scaleFactor) - ); - totalChips = MAX_CHIPS; - } - const distribution = totalChipsCount.map((chipCount) => - Math.floor(chipCount / playerCount) - ); + // Return the closest (but lower) valid denomination to the target + const findFloorDenomination = (target: number): validDenomination => { + let current: validDenomination = validDenominations[0]; + validDenominations.forEach((value, index) => { + if (value < target) current = value; + }); + return current; + }; - setChipDistribution(distribution); + // Bound for the value of the highest chip + // This is somewhat arbitray, but 1/3 to 1/4 is reasonable depending on the number of colors. + const maxDenomination = useMemo(() => { + if (totalChipsCount.length > 3) { + return findFloorDenomination(buyInAmount / 3); } else { - setChipDistribution([]); + return findFloorDenomination(buyInAmount / 4); } - }, [buyInAmount, playerCount, totalChipsCount]); + }, [totalChipsCount]); - const hasValidDistribution = useMemo( - () => - buyInAmount !== null && playerCount > 0 && chipDistribution.length > 0, - [buyInAmount, playerCount, chipDistribution] + // Total value of the pot + const potValue = useMemo( + () => buyInAmount * playerCount, + [buyInAmount, playerCount] ); + // The total value of all chips distributed to a single player. Ideally should be equal to buyInAmount + const totalValue = useMemo(() => { + let value = 0; + for (let i = 0; i < totalChipsCount.length; i++) { + value += distributions[i] * denominations[i]; + } + return value; + }, [distributions, denominations]); + + // Maximum quantity of each chip color which may be distributed to a single player before running out + const maxPossibleDistribution = useMemo( + () => totalChipsCount.map((v) => Math.floor(v / playerCount)), + [totalChipsCount, playerCount] + ); + + // Redenominate the chips in case of failure to properly distribute. + // Move the shuffle index to the next lowest denomination, and keep all else same + const redenominate = useCallback( + ( + invalidDenomination: validDenomination[], + shuffleIndex: number + ): validDenomination[] => { + let moved = false; + const newDenomination: validDenomination[] = []; + for (let i = invalidDenomination.length - 1; i >= 0; i--) { + if (i > shuffleIndex) { + newDenomination.push(invalidDenomination[i]); + } else if (i == shuffleIndex) { + newDenomination.push(invalidDenomination[i]); + } else if (i < shuffleIndex && !moved) { + const nextLowestDenominationIndex = Math.max( + validDenominations.indexOf(invalidDenomination[i]) - 1, + 0 + ); + newDenomination.push(validDenominations[nextLowestDenominationIndex]); + moved = true; + } else { + newDenomination.push(invalidDenomination[i]); + } + } + newDenomination.reverse(); + return newDenomination; + }, + [] + ); + + // Dynamically set denominations and distributions from changing inputs + useEffect(() => { + let testDenomination: validDenomination[] = []; + + const numColors = totalChipsCount.length; + const testDistribution: number[] = []; + for (let i = 0; i < numColors; ++i) { + testDistribution.push(0); + } + + // Start with max denominations, then push on the next adjacent lower denomination + testDenomination.push(maxDenomination); + let currentDenominationIndex: number = + validDenominations.indexOf(maxDenomination); + for (let i = numColors - 2; i >= 0; i = i - 1) { + currentDenominationIndex -= 1; + const currentDemoniation = validDenominations[currentDenominationIndex]; + testDenomination.push(currentDemoniation); + } + testDenomination.reverse(); + + // Distribute the chips using the test denomination + // If distribution fails to equal the buy-in, redenominate and re-try + // Algorithm could be improved with more complexity and optimization + let remainingValue = buyInAmount; + let fail = true; + let failCount = 0; + while (fail && failCount < 1) { + let stop = false; + while (remainingValue > 0 && !stop) { + let distributed = false; + for (let i = numColors - 1; i >= 0; i = i - 1) { + if (testDistribution[i] < maxPossibleDistribution[i]) { + if (remainingValue >= testDenomination[i]) { + testDistribution[i] = testDistribution[i] + 1; + remainingValue = remainingValue - testDenomination[i]; + distributed = true; + } + } + } + if (distributed == false) { + stop = true; + } + } + if (remainingValue !== 0) { + const redenominateIndex = failCount % numColors; + testDenomination = redenominate(testDenomination, redenominateIndex); + failCount += 1; + fail = true; + } else { + fail = false; + } + } + + setDenominations(testDenomination); + setDistributions(testDistribution); + }, [totalChipsCount, maxDenomination, buyInAmount, playerCount]); + return ( - Chip Distribution Summary: - {hasValidDistribution ? ( - chipDistribution.map((count, index) => ( - - {`${colors[index]?.toString().toUpperCase()} Chips: ${count} per player`} - - )) - ) : ( - - No valid distribution calculated yet. - - )} + Distribution & Denomination + ${potValue} Pot + + {totalChipsCount.map((_, index) => ( + + + {distributions[index]} chips: + + + ${denominations[index]} each + + + ))} + + Total Value:{totalValue} ); }; @@ -68,17 +201,39 @@ const styles = StyleSheet.create({ container: { marginTop: 20, padding: 15, - backgroundColor: "#F8F9FA", borderRadius: 10, + display: "flex", + alignItems: "center", }, title: { fontSize: 18, fontWeight: "bold", marginBottom: 10, }, - chipText: { + subTitle: { fontSize: 16, + color: "gray", + fontWeight: "bold", + marginBottom: 10, + }, + chipContainer: { + marginTop: 10, + }, + chipRow: { + display: "flex", + flexDirection: "row", + justifyContent: "center", + gap: 10, + }, + chipText: { + fontSize: 20, marginVertical: 2, + fontWeight: "bold", + }, + whiteShadow: { + textShadowColor: "black", + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 10, }, noDataText: { fontSize: 16, diff --git a/components/__tests__/BuyInSelector.test.tsx b/components/__tests__/BuyInSelector.test.tsx index 6f6706f..c7601be 100644 --- a/components/__tests__/BuyInSelector.test.tsx +++ b/components/__tests__/BuyInSelector.test.tsx @@ -54,7 +54,7 @@ describe("BuyInSelector Component", () => { fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-10"); - expect(setBuyInAmount).toHaveBeenCalledWith(null); + expect(setBuyInAmount).toHaveBeenCalledWith(25); }); it("clears the custom amount when selecting a predefined option", () => { @@ -80,9 +80,9 @@ describe("BuyInSelector Component", () => { expect(setBuyInAmount).toHaveBeenCalledWith(75); fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "-5"); - expect(setBuyInAmount).toHaveBeenCalledWith(null); + expect(setBuyInAmount).toHaveBeenCalledWith(25); fireEvent.changeText(getByPlaceholderText("Enter custom buy-in"), "abc"); - expect(setBuyInAmount).toHaveBeenCalledWith(null); + expect(setBuyInAmount).toHaveBeenCalledWith(25); }); }); diff --git a/components/__tests__/ChipDistributionSummary.test.tsx b/components/__tests__/ChipDistributionSummary.test.tsx index 2646cca..c864aa2 100644 --- a/components/__tests__/ChipDistributionSummary.test.tsx +++ b/components/__tests__/ChipDistributionSummary.test.tsx @@ -5,35 +5,45 @@ import ChipDistributionSummary from "../ChipDistributionSummary"; describe("ChipDistributionSummary Component", () => { test("renders correctly with valid data", () => { const playerCount = 4; - const totalChipsCount = [100, 200, 300, 400, 500]; + const totalChipsCount = [100, 80, 60, 40, 20]; const colors = ["WHITE", "RED", "GREEN", "BLUE", "BLACK"]; + const buyInAmount = 20; - // Update this to match the actual component's chip distribution logic - const expectedDistribution = [8, 16, 25, 33, 41]; // Adjust based on actual component calculations + const expectedDistribution = [2, 2, 1, 2, 2]; + const expectedDenominations = [0.5, 1, 2, 2.5, 5]; - const { getByText } = render( + const { getByText, getAllByText } = render( ); - expect(getByText("Chip Distribution Summary:")).toBeTruthy(); + expect(getByText("Distribution & Denomination")).toBeTruthy(); expectedDistribution.forEach((count, index) => { - expect(getByText(new RegExp(`${colors[index]} Chips: ${count} per player`, "i"))).toBeTruthy(); + expect(getAllByText(new RegExp(`${count} chips:`, "i"))).toBeTruthy(); + expect( + getByText(new RegExp(`\\$${expectedDenominations[index]} each`, "i")) + ).toBeTruthy(); }); }); - test("renders fallback message when no valid distribution", () => { + // Case not currently supported + test.skip("renders fallback message when no valid distribution", () => { const { getByText } = render( - + ); expect(getByText("No valid distribution calculated yet.")).toBeTruthy(); }); - test("scales down chips if exceeding MAX_CHIPS", () => { + // Case not currently supported + test.skip("scales down chips if exceeding MAX_CHIPS", () => { const playerCount = 2; let totalChipsCount = [300, 400, 500, 600, 700]; const MAX_CHIPS = 500; @@ -41,11 +51,12 @@ describe("ChipDistributionSummary Component", () => { if (totalChips > MAX_CHIPS) { const scaleFactor = MAX_CHIPS / totalChips; - totalChipsCount = totalChipsCount.map(count => Math.round(count * scaleFactor)); + totalChipsCount = totalChipsCount.map((count) => + Math.round(count * scaleFactor) + ); } const expectedDistribution = [30, 40, 50, 60, 70]; // Adjust to match actual component calculations - const colors = ["WHITE", "RED", "GREEN", "BLUE", "BLACK"]; const { getByText } = render( { /> ); - expect(getByText("Chip Distribution Summary:")).toBeTruthy(); + expect(getByText("Distribution & Denomination")).toBeTruthy(); expectedDistribution.forEach((count, index) => { - expect(getByText(new RegExp(`${colors[index]} Chips: ${count} per player`, "i"))).toBeTruthy(); + expect(getByText(new RegExp(`${count} chips:`, "i"))).toBeTruthy(); + // expect(getByText(new RegExp(`$${count} each`, "i"))).toBeTruthy(); }); }); }); -