From 73ee42a0ef38ec36ff6f763304b9521e44d9302a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 4 Jun 2026 22:15:43 +0300 Subject: [PATCH] fix(wheel): stop showing fake wins in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Stars payment the wheel matched its landing sector to the won prize by display_name/emoji and, on any miss, span to Math.random()*360 — which often pointed at a winning slot. The poll result also carried the SPIN id in prize_id (not the WheelPrize id), so it never matched and always hit that random fallback. In a backgrounded browser tab the poll often times out, so the wheel 'landed' on месяц/50₽ while the real result was Ничего; the Mini App was fine because it gets the result inline. - Match the sector by prize_id (exact), with name/emoji only as a defensive fallback for old payloads. - Never land on a random angle: when the prize can't be resolved (poll timeout/error) land on the neutral 'Nothing' sector instead. - Carry the real WheelPrize id (now exposed by the bot history endpoint) through the poll result. --- src/api/wheel.ts | 3 ++ src/pages/Wheel.tsx | 95 +++++++++++++++++++++++++++------------------ 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/src/api/wheel.ts b/src/api/wheel.ts index dd47f82..16a005f 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -60,6 +60,9 @@ export interface SpinResult { export interface SpinHistoryItem { id: number; + // WheelPrize id of the won prize (null if that prize was later deleted). Used to + // land the wheel animation on the exact winning sector after a Stars payment. + prize_id: number | null; payment_type: string; payment_amount: number; prize_type: string; diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 9f6d4d0..674ec8c 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -22,38 +22,57 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( ); /** - * Calculate rotation degrees for a prize based on its position on the wheel. + * Rotation (mod 360) that brings sector `prizeIndex` under the top pointer. * Mirrors the backend _calculate_rotation logic in wheel_service.py. */ -function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number { - // Find prize index by matching display_name + emoji (both must match for safety) - let prizeIndex = prizes.findIndex( - (p) => p.display_name === result.prize_display_name && p.emoji === result.emoji, - ); - - // Fallback: match by display_name only - if (prizeIndex === -1) { - prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name); - } - - // Fallback: match by emoji + prize_type - if (prizeIndex === -1) { - prizeIndex = prizes.findIndex( - (p) => p.emoji === result.emoji && p.prize_type === result.prize_type, - ); - } - - if (prizeIndex === -1) { - // Can't determine prize position — random angle - return Math.random() * 360; - } - +function rotationForIndex(prizes: WheelPrize[], prizeIndex: number): number { + if (prizes.length === 0) return 0; const sectorAngle = 360 / prizes.length; const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2; - const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector - const stopAngle = 360 - baseAngle + offset; + const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% within the sector + return 360 - baseAngle + offset; +} - return stopAngle; +/** Index of the "Nothing" sector, or 0 if there isn't one. */ +function neutralIndex(prizes: WheelPrize[]): number { + const idx = prizes.findIndex((p) => p.prize_type === 'nothing'); + return idx === -1 ? 0 : idx; +} + +/** + * Rotation that lands the wheel on the ACTUAL won prize's sector. + * + * Matches by prize_id (exact). It must NEVER land on a random sector: a random + * angle was the root cause of "the wheel shows месяц/50₽ but the result is + * Ничего" on the browser Stars path — when the prize couldn't be located the old + * code span to Math.random()*360, which often pointed at a winning slot. When the + * prize can't be resolved we land on the neutral ("Nothing") sector instead, so + * the animation never falsely celebrates a win. + */ +function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number { + let prizeIndex = result.prize_id != null ? prizes.findIndex((p) => p.id === result.prize_id) : -1; + + // Defensive fallbacks for older payloads without prize_id: name+emoji, then name. + if (prizeIndex === -1 && result.prize_display_name) { + prizeIndex = prizes.findIndex( + (p) => p.display_name === result.prize_display_name && p.emoji === result.emoji, + ); + if (prizeIndex === -1) { + prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name); + } + } + + if (prizeIndex === -1) prizeIndex = neutralIndex(prizes); // unknown → neutral, never random + + return rotationForIndex(prizes, prizeIndex); +} + +/** + * Rotation used when the real result is unknown (e.g. the Stars-payment poll timed + * out). Lands on the neutral ("Nothing") sector — never a random/winning angle. + */ +function neutralRotation(prizes: WheelPrize[]): number { + return rotationForIndex(prizes, neutralIndex(prizes)); } export default function Wheel() { @@ -144,7 +163,10 @@ export default function Wheel() { // Found a new spin! Return it as SpinResult return { success: true, - prize_id: latestSpin.id, + // WheelPrize id — lets the wheel land on the exact won sector. + // (Was latestSpin.id, the SPIN id, which never matched a sector and + // forced the random-angle fallback → the fake-win bug.) + prize_id: latestSpin.prize_id, prize_type: latestSpin.prize_type, prize_value: latestSpin.prize_value, prize_display_name: latestSpin.prize_display_name, @@ -219,13 +241,11 @@ export default function Wheel() { if (result) { pendingStarsResultRef.current = result; - // Calculate rotation so the wheel lands on the correct prize sector - const rotation = config?.prizes - ? calculateRotationForPrize(config.prizes, result) - : Math.random() * 360; - setTargetRotation(rotation); + // Land on the ACTUAL won sector (matched by prize_id). + setTargetRotation(calculateRotationForPrize(config?.prizes ?? [], result)); } else { - // Fallback: couldn't get result — use random rotation + // Couldn't get the result — land on the neutral sector (never a + // random/winning angle) and tell the user to check their history. pendingStarsResultRef.current = { success: true, prize_id: null, @@ -239,7 +259,7 @@ export default function Wheel() { promocode: null, error: null, }; - setTargetRotation(Math.random() * 360); + setTargetRotation(neutralRotation(config?.prizes ?? [])); } setIsSpinning(true); @@ -249,7 +269,8 @@ export default function Wheel() { setIsPayingStars(false); - // Error polling — spin with random rotation and show generic message + // Error polling — land on the neutral sector (never a random/winning + // angle) and show the generic "check your history" message. pendingStarsResultRef.current = { success: true, prize_id: null, @@ -263,7 +284,7 @@ export default function Wheel() { promocode: null, error: null, }; - setTargetRotation(Math.random() * 360); + setTargetRotation(neutralRotation(config?.prizes ?? [])); setIsSpinning(true); }); } else if (status !== 'cancelled') {