diff --git a/src/assets/fonts/countdown.TTF b/src/assets/fonts/countdown.TTF deleted file mode 100644 index 035190b..0000000 Binary files a/src/assets/fonts/countdown.TTF and /dev/null differ diff --git a/src/assets/fonts/框内可滑动@2x.png b/src/assets/fonts/框内可滑动@2x.png deleted file mode 100644 index 580de1c..0000000 Binary files a/src/assets/fonts/框内可滑动@2x.png and /dev/null differ diff --git a/src/assets/game/confirm-bg.webp b/src/assets/game/confirm-bg.webp new file mode 100644 index 0000000..c16ef28 Binary files /dev/null and b/src/assets/game/confirm-bg.webp differ diff --git a/src/assets/game/confirm-red-bg.png b/src/assets/game/confirm-red-bg.png new file mode 100644 index 0000000..6d23c3d Binary files /dev/null and b/src/assets/game/confirm-red-bg.png differ diff --git a/src/assets/game/confirm-red-bg.webp b/src/assets/game/confirm-red-bg.webp new file mode 100644 index 0000000..a02c6b5 Binary files /dev/null and b/src/assets/game/confirm-red-bg.webp differ diff --git a/src/assets/music/hall-music.mp3 b/src/assets/music/hall-music.mp3 new file mode 100644 index 0000000..9fa9e61 Binary files /dev/null and b/src/assets/music/hall-music.mp3 differ diff --git a/src/assets/system/en-US.png b/src/assets/system/en-US.png new file mode 100644 index 0000000..4cd1fa6 Binary files /dev/null and b/src/assets/system/en-US.png differ diff --git a/src/assets/system/id-ID.webp b/src/assets/system/id-ID.webp new file mode 100644 index 0000000..209535f Binary files /dev/null and b/src/assets/system/id-ID.webp differ diff --git a/src/assets/system/ms-MY.png b/src/assets/system/ms-MY.png new file mode 100644 index 0000000..efe714a Binary files /dev/null and b/src/assets/system/ms-MY.png differ diff --git a/src/assets/system/zh-CN.png b/src/assets/system/zh-CN.png new file mode 100644 index 0000000..259c44a Binary files /dev/null and b/src/assets/system/zh-CN.png differ diff --git a/src/components/fullscreen-lottie-overlay.tsx b/src/components/fullscreen-lottie-overlay.tsx new file mode 100644 index 0000000..584f853 --- /dev/null +++ b/src/components/fullscreen-lottie-overlay.tsx @@ -0,0 +1,122 @@ +import type { + AnimationDirection, + AnimationEventCallback, + AnimationEvents, + RendererType, +} from 'lottie-web' +import { type ReactNode, useEffect } from 'react' +import { createPortal } from 'react-dom' +import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts' +import { LottiePlayer } from '@/components/lottie-player.tsx' +import { cn } from '@/lib/utils' + +interface FullscreenLottieOverlayProps { + open: boolean + source: FullscreenLottieSource | null + animationKey?: string + zIndex?: number + renderer?: RendererType + loop?: boolean | number + autoplay?: boolean + speed?: number + direction?: AnimationDirection + lockBodyScroll?: boolean + closeOnBackdrop?: boolean + backdropClassName?: string + viewportClassName?: string + playerClassName?: string + onRequestClose?: () => void + onComplete?: AnimationEventCallback + children?: ReactNode +} + +/** + * @description 全屏 Lottie 播放容器。 + * 通过外部传入不同的 `source` 和 `animationKey`,可在同一个容器里切换多种开奖/结算动画。 + */ +export function FullscreenLottieOverlay({ + open, + source, + animationKey, + zIndex = 80, + renderer = 'svg', + loop, + autoplay, + speed, + direction, + lockBodyScroll = true, + closeOnBackdrop = false, + backdropClassName, + viewportClassName, + playerClassName, + onRequestClose, + onComplete, + children, +}: FullscreenLottieOverlayProps) { + useEffect(() => { + if (!open || !lockBodyScroll || typeof document === 'undefined') { + return + } + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = previousOverflow + } + }, [lockBodyScroll, open]) + + if (!open || !source || typeof document === 'undefined') { + return null + } + + const playerKey = `${animationKey ?? source.id}-${source.id}` + + return createPortal( +
+
, + document.body, + ) +} diff --git a/src/components/fullscreen-lottie-overlay.types.ts b/src/components/fullscreen-lottie-overlay.types.ts new file mode 100644 index 0000000..3175f9e --- /dev/null +++ b/src/components/fullscreen-lottie-overlay.types.ts @@ -0,0 +1,17 @@ +import type { + AnimationConfigWithData, + AnimationConfigWithPath, + AnimationDirection, + RendererType, +} from 'lottie-web' + +export interface FullscreenLottieSource { + id: string + animationData?: AnimationConfigWithData['animationData'] + path?: AnimationConfigWithPath['path'] + loop?: boolean | number + autoplay?: boolean + speed?: number + direction?: AnimationDirection + renderer?: RendererType +} diff --git a/src/constants/index.ts b/src/constants/index.ts index f8c3c62..0cfc8d7 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -8,6 +8,11 @@ import chip6 from '@/assets/game/chip6.webp' import controlLeft from '@/assets/game/control-left.webp' import controlMid from '@/assets/game/control-mid.webp' import controlRight from '@/assets/game/control-right.webp' +import enUS from '@/assets/system/en-US.png' +import idID from '@/assets/system/id-ID.webp' +import msMY from '@/assets/system/ms-MY.png' +import zhCN from '@/assets/system/zh-CN.png' +import type { AppLanguage } from '@/i18n' /** @description 应用启动阶段使用的根节点常量。 */ export const APP_ROOT_ELEMENT_ID = 'root' @@ -25,6 +30,9 @@ export const AUTH_STORAGE_KEY = 'auth-session' /** @description 应用偏好持久化到浏览器时使用的存储键。 */ export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences' +/** @description 音频偏好持久化到浏览器时使用的存储键。 */ +export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences' + /** @description 接口请求的默认超时时间,单位为毫秒。 */ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000 @@ -96,3 +104,34 @@ export const ACTION_OPTIONS = [ bg: controlRight, }, ] + +export const LANGUAGE_OPTIONS: Array<{ + code: AppLanguage + icon: string + labelKey: string +}> = [ + { + code: 'zh-CN', + icon: zhCN, + labelKey: 'language.zhCN', + }, + { + code: 'en-US', + icon: enUS, + labelKey: 'language.enUS', + }, + { + code: 'ms-MY', + icon: msMY, + labelKey: 'language.msMY', + }, + { + code: 'id-ID', + icon: idID, + labelKey: 'language.idID', + }, +] + +export const LANGUAGE_ICON_MAP = new Map( + LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const), +) diff --git a/src/features/game/api/game-api.ts b/src/features/game/api/game-api.ts index 42e0bb1..28416d7 100644 --- a/src/features/game/api/game-api.ts +++ b/src/features/game/api/game-api.ts @@ -16,7 +16,7 @@ import type { TrendEntry, } from '../shared' import { - createMockGameBootstrapSnapshot, + createEmptyGameBootstrapSnapshot, DEFAULT_GAME_CHIP_COLORS, deriveTrendEntries, GAME_GRID_COLUMNS, @@ -33,8 +33,9 @@ import type { GameBootstrapDto, GameCellDto, GameLobbyInitDto, - GameLobbyPeriodDto, GamePeriodTickDto, + GamePlaceBetDto, + GamePlaceBetRequestDto, GameRoundFeedDto, HistoryEntryDto, NoticeConfirmDto, @@ -80,11 +81,13 @@ function assertLobbyInitDto( export const GAME_API_ENDPOINTS = { announcements: 'game/announcements', betMyOrders: 'api/game/betMyOrders', + betPlaceLegacy: 'api/game/betPlace', bootstrap: 'game/bootstrap', lobbyInit: 'api/game/lobbyInit', noticeConfirm: 'api/notice/noticeConfirm', noticeDetail: 'api/notice/noticeDetail', noticeList: 'api/notice/noticeList', + placeBet: 'api/game/placeBet', roundFeed: 'game/round-feed', } as const @@ -271,38 +274,6 @@ function normalizeLobbyCells(dictionary: GameLobbyInitDto['dictionary']) { ) } -export function normalizeLobbyRound( - lobbyInit: Pick< - GameLobbyInitDto, - 'period' | 'runtime_enabled' | 'server_time' - >, -) { - if (!lobbyInit.period) { - return { - bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.server_time), - id: '', - phase: 'waiting', - revealingAt: toIsoFromUnixSeconds(lobbyInit.server_time), - settledAt: null, - startedAt: toIsoFromUnixSeconds(lobbyInit.server_time), - winningCellId: null, - } satisfies RoundSnapshot - } - - return { - bettingClosesAt: toIsoFromUnixSeconds(lobbyInit.period.lock_at), - id: lobbyInit.period.period_no, - phase: normalizeLobbyRoundPhase( - lobbyInit.period.status, - lobbyInit.runtime_enabled, - ), - revealingAt: toIsoFromUnixSeconds(lobbyInit.period.open_at), - settledAt: toIsoFromUnixSeconds(lobbyInit.period.open_at), - startedAt: toIsoFromUnixSeconds(lobbyInit.server_time), - winningCellId: null, - } satisfies RoundSnapshot -} - export function normalizePeriodTickRound( period: GamePeriodTickDto, previousRound?: Pick | null, @@ -332,17 +303,12 @@ export function normalizePeriodTickRound( export function normalizeGameLobbyInit(dto: GameLobbyInitDto) { const baseIso = toIsoFromUnixSeconds(dto.server_time) - const template = createMockGameBootstrapSnapshot(baseIso) + const template = createEmptyGameBootstrapSnapshot(baseIso) const cells = normalizeLobbyCells(dto.dictionary) const chips = normalizeLobbyChips( dto.bet_config.chips, dto.bet_config.default_bet_chip_id, ) - const round = normalizeLobbyRound({ - period: null, - runtime_enabled: dto.runtime_enabled, - server_time: dto.server_time, - }) const trends = deriveTrendEntries([]) return { @@ -355,12 +321,6 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) { chips: chips.length > 0 ? chips : template.chips, connection: { ...template.connection, - connectedAt: null, - lastError: null, - lastMessageAt: null, - latencyMs: null, - reconnectAttempt: 0, - status: 'idle', transport: 'polling', }, dashboard: { @@ -378,7 +338,7 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) { dto.bet_config.pick_max_number_count > 0 ? Math.min(36, Math.floor(dto.bet_config.pick_max_number_count)) : GAME_MAX_SELECTION_CELLS, - round, + round: template.round, selections: [], trends, } satisfies GameBootstrapSnapshot @@ -534,10 +494,17 @@ export async function getGameBetMyOrders(params: { return dto } -export async function getMockGameBootstrap(latencyMs = 120) { - await new Promise((resolve) => { - setTimeout(resolve, latencyMs) - }) +export async function placeGameBet(payload: GamePlaceBetRequestDto) { + const response = await api.post( + GAME_API_ENDPOINTS.placeBet, + { + json: payload, + }, + ) + const dto = unwrapGameEnvelope( + response as ApiResponse, + 'Failed to place game bet', + ) - return createMockGameBootstrapSnapshot() + return dto } diff --git a/src/features/game/api/types.ts b/src/features/game/api/types.ts index 831e787..b8e687c 100644 --- a/src/features/game/api/types.ts +++ b/src/features/game/api/types.ts @@ -233,6 +233,23 @@ export interface GameBetOrdersDto { pagination: GameBetOrdersPaginationDto } +export interface GamePlaceBetRequestDto { + bet_id: number + idempotency_key: string + numbers: string + period_no: string +} + +export interface GamePlaceBetDto { + balance_after: string + current_streak: number + locked_balance?: string + numbers_count: number + order_no: string + period_no: string + status: 'accepted' | 'rejected' | (string & {}) +} + export type { AnnouncementState, Chip, diff --git a/src/features/game/audio/audio-config.ts b/src/features/game/audio/audio-config.ts new file mode 100644 index 0000000..ac98240 --- /dev/null +++ b/src/features/game/audio/audio-config.ts @@ -0,0 +1,19 @@ +import hallMusic from '@/assets/music/hall-music.mp3' + +export type AudioAssetId = 'hall-bgm' + +export type AudioAssetDefinition = { + id: AudioAssetId + loop?: boolean + src: string + volume?: number +} + +export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [ + { + id: 'hall-bgm', + src: hallMusic, + loop: true, + volume: 1, + }, +] diff --git a/src/features/game/audio/global-audio-controller.tsx b/src/features/game/audio/global-audio-controller.tsx new file mode 100644 index 0000000..83aae91 --- /dev/null +++ b/src/features/game/audio/global-audio-controller.tsx @@ -0,0 +1,103 @@ +import { useEffect } from 'react' + +import { + AUDIO_ASSET_DEFINITIONS, + type AudioAssetDefinition, +} from '@/features/game/audio/audio-config' +import { useAudioStore } from '@/store' + +function createAudioInstance(definition: AudioAssetDefinition) { + const audio = new Audio(definition.src) + audio.preload = 'auto' + audio.loop = definition.loop ?? false + audio.volume = definition.volume ?? 1 + + return audio +} + +export function GlobalAudioController() { + const hasUnlockedSoundPlayback = useAudioStore( + (state) => state.hasUnlockedSoundPlayback, + ) + const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled) + + useEffect(() => { + const audioEntries = AUDIO_ASSET_DEFINITIONS.map((definition) => ({ + audio: createAudioInstance(definition), + definition, + })) + + let isDisposed = false + let detachResumeListeners: (() => void) | null = null + + const stopAllAudio = () => { + audioEntries.forEach(({ audio }) => { + audio.pause() + audio.currentTime = 0 + }) + } + + const playEnabledAudio = async () => { + const audioState = useAudioStore.getState() + + if ( + isDisposed || + !audioState.hasUnlockedSoundPlayback || + !audioState.isSoundEnabled + ) { + return + } + + const playResults = await Promise.allSettled( + audioEntries.map(async ({ audio }) => { + audio.currentTime = 0 + await audio.play() + }), + ) + + const hasBlockedAudio = playResults.some( + (result) => result.status === 'rejected', + ) + + if (!hasBlockedAudio || detachResumeListeners) { + return + } + + const resumePlayback = () => { + detachResumeListeners?.() + detachResumeListeners = null + void playEnabledAudio() + } + + const events: Array = [ + 'pointerdown', + 'keydown', + 'touchstart', + ] + + events.forEach((eventName) => { + window.addEventListener(eventName, resumePlayback, { once: true }) + }) + + detachResumeListeners = () => { + events.forEach((eventName) => { + window.removeEventListener(eventName, resumePlayback) + }) + } + } + + if (hasUnlockedSoundPlayback && isSoundEnabled) { + void playEnabledAudio() + } else { + stopAllAudio() + } + + return () => { + isDisposed = true + detachResumeListeners?.() + stopAllAudio() + } + }, [hasUnlockedSoundPlayback, isSoundEnabled]) + + return null +} diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx index 8bd05ba..7f1ca41 100644 --- a/src/features/game/components/desktop/desktop-animal.tsx +++ b/src/features/game/components/desktop/desktop-animal.tsx @@ -4,7 +4,7 @@ import diamondIcon from '@/assets/system/diamond.webp' import { SmartImage } from '@/components/smart-image' import { notify } from '@/lib/notify' import { cn } from '@/lib/utils' -import { useAuthStore, useModalStore } from '@/store' +import { useAudioStore, useAuthStore, useModalStore } from '@/store' import { useGameRoundStore, useGameSessionStore } from '@/store/game' const animalModules = import.meta.glob('../../../../assets/animal/*.webp', { @@ -72,6 +72,9 @@ export function DesktopAnimal({ }: DesktopAnimalProps) { const { t } = useTranslation() const authStatus = useAuthStore((state) => state.status) + const markSoundPlaybackUnlocked = useAudioStore( + (state) => state.markSoundPlaybackUnlocked, + ) const setModalOpen = useModalStore((state) => state.setModalOpen) const activeChipId = useGameRoundStore((state) => state.activeChipId) const chips = useGameRoundStore((state) => state.chips) @@ -132,6 +135,7 @@ export function DesktopAnimal({ } clearSelections() + markSoundPlaybackUnlocked() requestRealtimeConnection() } diff --git a/src/features/game/components/desktop/desktop-control.tsx b/src/features/game/components/desktop/desktop-control.tsx index e7bdd3b..a86c2e6 100644 --- a/src/features/game/components/desktop/desktop-control.tsx +++ b/src/features/game/components/desktop/desktop-control.tsx @@ -5,7 +5,8 @@ import add from '@/assets/game/add.webp' import arrow from '@/assets/game/arrow.webp' import chipBg from '@/assets/game/chip-bg.webp' import chipLineBg from '@/assets/game/chip-line-bg.webp' -import confirmBg from '@/assets/game/confirm-bg.png' +import confirmBg from '@/assets/game/confirm-bg.webp' +import confirmRedBg from '@/assets/game/confirm-red-bg.png' import controlBg from '@/assets/game/control-bg.png' import leftBottomBg from '@/assets/game/left-bg.webp' import reduce from '@/assets/game/reduce.webp' @@ -21,9 +22,14 @@ export function DesktopControl() { const { canClear, chips, + confirmLabel, + confirmState, + isConfirmClickable, maxSelectionCountLabel, onChipSelect, + onConfirm, onClearSelections, + onRepeatSelections, selectedChipAmountLabel, selectedChipId, selectedCountLabel, @@ -43,6 +49,10 @@ export function DesktopControl() { onClearSelections() } + if (id === 'repeat') { + onRepeatSelections() + } + setClickedId(id) setTimeout(() => { setClickedId(null) @@ -52,15 +62,21 @@ export function DesktopControl() { }, 180) }, 200) }, - [canClear, onClearSelections], + [canClear, onClearSelections, onRepeatSelections], ) const handleConfirmClick = useCallback(() => { + if (!isConfirmClickable) { + void onConfirm() + return + } + setConfirmClicked(true) setTimeout(() => { setConfirmClicked(false) }, 200) - }, []) + void onConfirm() + }, [isConfirmClickable, onConfirm]) return (
{confirmClicked && ( @@ -381,16 +429,46 @@ export function DesktopControl() { exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="pointer-events-none absolute inset-0 bg-center bg-no-repeat" - src={confirmBg} + src={confirmState === 'insufficient' ? confirmRedBg : confirmBg} size="100% 100%" /> )} - {t('gameDesktop.control.confirm')} + {confirmLabel}
diff --git a/src/features/game/components/desktop/desktop-game-history.tsx b/src/features/game/components/desktop/desktop-game-history.tsx index 0bb262e..2d53b68 100644 --- a/src/features/game/components/desktop/desktop-game-history.tsx +++ b/src/features/game/components/desktop/desktop-game-history.tsx @@ -1,5 +1,4 @@ -import { useVirtualizer } from '@tanstack/react-virtual' -import { useEffect, useRef } from 'react' +import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import historyBg from '@/assets/system/history-bg.png' import { SmartBackground } from '@/components/smart-background.tsx' @@ -20,35 +19,20 @@ export function DesktopGameHistory() { } = useGameHistoryVm() const parentRef = useRef(null) - const rowCount = hasNextPage ? items.length + 1 : items.length - const virtualizer = useVirtualizer({ - count: rowCount, - estimateSize: () => 196, - getScrollElement: () => parentRef.current, - overscan: 4, - }) + const handleScroll = useCallback(() => { + const element = parentRef.current - useEffect(() => { - const virtualItems = virtualizer.getVirtualItems() - const lastItem = virtualItems[virtualItems.length - 1] - - if ( - !lastItem || - !hasNextPage || - isFetchingNextPage || - lastItem.index < items.length - 1 - ) { + if (!element || !hasNextPage || isFetchingNextPage) { return } - void fetchNextPage() - }, [ - fetchNextPage, - hasNextPage, - isFetchingNextPage, - items.length, - virtualizer, - ]) + const distanceToBottom = + element.scrollHeight - element.scrollTop - element.clientHeight + + if (distanceToBottom <= 120) { + void fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) return (
) : ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const item = items[virtualRow.index] - - return ( + <> + {items.map((item) => ( +
- {item ? ( -
-
- {item.statusLabel} -
-
-
- - {t('gameDesktop.history.orderNo')}:{' '} - - - {item.orderNo} - -
-
- - {t('gameDesktop.history.roundId')}:{' '} - - - {item.periodNo} - -
-
- - {t('gameDesktop.history.numbers')}:{' '} - - {item.numbersLabel} -
-
- - {t('gameDesktop.history.settledAt')}:{' '} - - {item.createdAtLabel} -
-
- - {t('gameDesktop.history.totalPoolAmount')}:{' '} - - - {item.amountLabel} - -
-
- - {t('gameDesktop.history.winningResult')}:{' '} - - - {item.resultNumberLabel} - -
-
- - {t('gameDesktop.history.payout')}:{' '} - - {item.winAmountLabel} -
-
+
+ {item.statusLabel} +
+
+
+ + {t('gameDesktop.history.orderNo')}:{' '} + + {item.orderNo}
- ) : ( -
- {isFetchingNextPage ? loadingText : endText} +
+ + {t('gameDesktop.history.roundId')}:{' '} + + {item.periodNo}
- )} +
+ + {t('gameDesktop.history.numbers')}:{' '} + + {item.numbersLabel} +
+
+ + {t('gameDesktop.history.settledAt')}:{' '} + + {item.createdAtLabel} +
+
+ + {t('gameDesktop.history.totalPoolAmount')}:{' '} + + + {item.amountLabel} + +
+
+ + {t('gameDesktop.history.winningResult')}:{' '} + + + {item.resultNumberLabel} + +
+
+ + {t('gameDesktop.history.payout')}:{' '} + + {item.winAmountLabel} +
+
- ) - })} -
+
+ ))} +
+ {isFetchingNextPage ? loadingText : hasNextPage ? '' : endText} +
+ )}
diff --git a/src/features/game/components/desktop/desktop-header.tsx b/src/features/game/components/desktop/desktop-header.tsx index b483b90..faa6b82 100644 --- a/src/features/game/components/desktop/desktop-header.tsx +++ b/src/features/game/components/desktop/desktop-header.tsx @@ -1,16 +1,29 @@ -import { CircleAlert, Mail, Maximize, Minimize, Volume2 } from 'lucide-react' +import { + CircleAlert, + Mail, + Maximize, + Minimize, + Volume2, + VolumeX, +} from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import avatar from '@/assets/system/avatar.webp' import diamond from '@/assets/system/diamond.webp' import logo from '@/assets/system/logo.webp' import { SmartImage } from '@/components/smart-image.tsx' +import { useAppLanguage } from '@/features/game/hooks/use-app-language' import { isDesktopFullscreen, subscribeDesktopFullscreenChange, toggleDesktopFullscreen, } from '@/lib/utils' -import { useAuthStore, useGameSessionStore, useModalStore } from '@/store' +import { + useAudioStore, + useAuthStore, + useGameSessionStore, + useModalStore, +} from '@/store' type BrowserNetworkInformation = { addEventListener?: (type: 'change', listener: () => void) => void @@ -155,8 +168,11 @@ export function DesktopHeader() { ) const currentUser = useAuthStore((state) => state.currentUser) const authStatus = useAuthStore((state) => state.status) + const isSoundEnabled = useAudioStore((state) => state.isSoundEnabled) + const toggleSoundEnabled = useAudioStore((state) => state.toggleSoundEnabled) const connection = useGameSessionStore((state) => state.connection) const setModalOpen = useModalStore((state) => state.setModalOpen) + const { currentLanguageLabel, currentLanguageOption } = useAppLanguage() const serverClockOffsetMs = useMemo(() => { if ( @@ -286,24 +302,50 @@ export function DesktopHeader() {
-
+
+
{t('gameDesktop.header.message')}
-
- +
+ -
- -
{t('gameDesktop.header.id')}
+
+
+ {/* 桌面端登录弹窗:用于未登录用户进入登录流程 */} + {/* 桌面端注册弹窗:用于新用户注册账号 */} + {/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */} + + {/* 桌面端协议弹窗:首次进入站点时强制同意协议后才可继续 */} + + {/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */} + + {/* 桌面端用户信息弹窗:展示个人资料与站内消息 */} + {/* 桌面端公告弹窗:展示活动公告或运营通知内容 */} + {/* 桌面端自动托管弹窗:配置自动托管相关条件 */} + {/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */} + {/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */} ) diff --git a/src/features/game/hooks/use-app-language.ts b/src/features/game/hooks/use-app-language.ts new file mode 100644 index 0000000..984406e --- /dev/null +++ b/src/features/game/hooks/use-app-language.ts @@ -0,0 +1,55 @@ +import { useLocation } from '@tanstack/react-router' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { LANGUAGE_OPTIONS } from '@/constants' +import { type AppLanguage, supportedLanguages } from '@/i18n' + +const languagePrefixPattern = new RegExp( + `^/(${supportedLanguages.join('|')})(?=/|$)`, +) + +function resolveNextPathname(pathname: string, language: AppLanguage) { + if (languagePrefixPattern.test(pathname)) { + return pathname.replace(languagePrefixPattern, `/${language}`) + } + + return `/${language}${pathname.startsWith('/') ? pathname : `/${pathname}`}` +} + +export function useAppLanguage() { + const { i18n, t } = useTranslation() + const location = useLocation() + + const currentLanguage = (i18n.resolvedLanguage ?? + i18n.language ?? + 'zh-CN') as AppLanguage + + const currentLanguageOption = useMemo( + () => + LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ?? + LANGUAGE_OPTIONS[0], + [currentLanguage], + ) + + const selectLanguage = async (language: AppLanguage) => { + if (language === currentLanguage) { + return + } + + await i18n.changeLanguage(language) + + const nextPathname = resolveNextPathname(location.pathname, language) + + window.location.assign( + `${nextPathname}${window.location.search}${window.location.hash}`, + ) + } + + return { + currentLanguage, + currentLanguageLabel: t(currentLanguageOption.labelKey), + currentLanguageOption, + languageOptions: LANGUAGE_OPTIONS, + selectLanguage, + } +} diff --git a/src/features/game/hooks/use-game-control-vm.ts b/src/features/game/hooks/use-game-control-vm.ts index 3710396..9860023 100644 --- a/src/features/game/hooks/use-game-control-vm.ts +++ b/src/features/game/hooks/use-game-control-vm.ts @@ -1,7 +1,13 @@ -import { useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants' +import { placeGameBet } from '@/features/game' +import { notify } from '@/lib/notify' +import { useAuthStore, useModalStore } from '@/store' import { selectSelectionTotal, useGameRoundStore } from '@/store/game' +type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'submitting' + function formatChipDisplayValue(amount: number) { if (Number.isInteger(amount)) { return String(amount) @@ -10,16 +16,66 @@ function formatChipDisplayValue(amount: number) { return amount.toFixed(2).replace(/\.?0+$/, '') } +function parseBalance(value: string | number | null | undefined) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0 + } + + if (typeof value !== 'string') { + return 0 + } + + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : 0 +} + +function createIdempotencyKey() { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return `bet-${crypto.randomUUID()}` + } + + return `bet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function toBetId(chipId: string) { + const match = chipId.match(/^chip-(\d+)$/) + + if (!match) { + return null + } + + const betId = Number(match[1]) + + return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null +} + export function useGameControlVm() { + const { t } = useTranslation() const chips = useGameRoundStore((state) => state.chips) const activeChipId = useGameRoundStore((state) => state.activeChipId) + const round = useGameRoundStore((state) => state.round) const maxSelectionCount = useGameRoundStore( (state) => state.maxSelectionCount, ) const selections = useGameRoundStore((state) => state.selections) const clearSelections = useGameRoundStore((state) => state.clearSelections) + const restoreRecentSuccessfulSelections = useGameRoundStore( + (state) => state.restoreRecentSuccessfulSelections, + ) + const setRecentSuccessfulSelections = useGameRoundStore( + (state) => state.setRecentSuccessfulSelections, + ) const selectChip = useGameRoundStore((state) => state.selectChip) const totalBetAmount = useGameRoundStore(selectSelectionTotal) + const authStatus = useAuthStore((state) => state.status) + const currentUser = useAuthStore((state) => state.currentUser) + const setCurrentUser = useAuthStore((state) => state.setCurrentUser) + const setModalOpen = useModalStore((state) => state.setModalOpen) + const [isSubmitting, setIsSubmitting] = useState(false) const chipItems = useMemo(() => { const items = chips.map((chip) => ({ @@ -41,11 +97,160 @@ export function useGameControlVm() { const selectedChip = chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null + const balance = parseBalance(currentUser?.coin) + const hasSelections = selections.length > 0 + const hasInsufficientBalance = hasSelections && totalBetAmount > balance + const confirmState: ConfirmState = isSubmitting + ? 'submitting' + : !hasSelections + ? 'idle' + : hasInsufficientBalance + ? 'insufficient' + : 'ready' + + const handleConfirm = useCallback(async () => { + if (confirmState === 'submitting' || !hasSelections) { + return + } + + if (authStatus !== 'authenticated') { + notify.warning(t('commonUi.toast.loginRequired')) + setModalOpen('desktopLogin', true) + return + } + + if (hasInsufficientBalance) { + notify.warning(t('commonUi.toast.insufficientBalance')) + return + } + + if (round.phase !== 'betting' || !round.id) { + notify.warning(t('commonUi.toast.betUnavailable')) + return + } + + const groupedSelections = selections.reduce< + Map + >((accumulator, selection) => { + const betId = toBetId(selection.chipId) + + if (betId === null) { + return accumulator + } + + const groupKey = String(betId) + const current = accumulator.get(groupKey) + + if (current) { + current.numbers.push(selection.cellId) + return accumulator + } + + accumulator.set(groupKey, { + betId, + numbers: [selection.cellId], + }) + + return accumulator + }, new Map()) + + if (groupedSelections.size === 0) { + notify.warning(t('commonUi.toast.betUnavailable')) + return + } + + setIsSubmitting(true) + + try { + let latestBalance = currentUser?.coin ?? '0' + let latestStreak = currentUser?.currentStreak ?? 0 + + for (const group of groupedSelections.values()) { + const uniqueNumbers = [...new Set(group.numbers)].sort( + (left, right) => left - right, + ) + const result = await placeGameBet({ + bet_id: group.betId, + idempotency_key: createIdempotencyKey(), + numbers: uniqueNumbers.join(','), + period_no: round.id, + }) + + if (result.status !== 'accepted') { + throw new Error(t('commonUi.toast.betRejected')) + } + + latestBalance = result.balance_after + latestStreak = result.current_streak + } + + if (currentUser) { + setCurrentUser({ + ...currentUser, + coin: latestBalance, + currentStreak: latestStreak, + lastBetPeriodNo: round.id, + }) + } + + setRecentSuccessfulSelections(selections) + clearSelections() + notify.success(t('commonUi.toast.betPlaced')) + } catch (error) { + notify.error(t('commonUi.toast.betPlaceFailed'), { + description: error instanceof Error ? error.message : undefined, + }) + } finally { + setIsSubmitting(false) + } + }, [ + authStatus, + clearSelections, + confirmState, + currentUser, + hasInsufficientBalance, + hasSelections, + round.id, + round.phase, + selections, + setRecentSuccessfulSelections, + setCurrentUser, + setModalOpen, + t, + ]) + + const handleRepeatSelections = useCallback(() => { + if (round.phase !== 'betting') { + notify.warning(t('commonUi.toast.betUnavailable')) + return + } + + const restored = restoreRecentSuccessfulSelections() + + if (!restored) { + notify.warning(t('commonUi.toast.noRecentSuccessfulBet')) + return + } + + notify.success(t('commonUi.toast.repeatSelectionsRestored')) + }, [restoreRecentSuccessfulSelections, round.phase, t]) return { canClear: selections.length > 0, + confirmLabel: + confirmState === 'idle' + ? t('gameDesktop.control.selectNumbers') + : confirmState === 'insufficient' + ? t('gameDesktop.control.insufficientBalance') + : confirmState === 'submitting' + ? t('gameDesktop.control.submitting') + : t('gameDesktop.control.confirm'), + confirmState, + isConfirmClickable: confirmState === 'ready', onChipSelect: selectChip, + onConfirm: handleConfirm, onClearSelections: clearSelections, + onRepeatSelections: handleRepeatSelections, maxSelectionCountLabel: maxSelectionCount, selectedChipAmountLabel: selectedChip?.valueLabel ?? '--', selectedChipId: activeChipId, diff --git a/src/features/game/hooks/use-game-history-vm.ts b/src/features/game/hooks/use-game-history-vm.ts index a05aa0d..626437c 100644 --- a/src/features/game/hooks/use-game-history-vm.ts +++ b/src/features/game/hooks/use-game-history-vm.ts @@ -1,9 +1,10 @@ import { useInfiniteQuery } from '@tanstack/react-query' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { getGameBetMyOrders } from '@/features/game/api/game-api' import { useAuthStore } from '@/store/auth' +import { useGameRoundStore } from '@/store/game' const GAME_HISTORY_PAGE_SIZE = 20 @@ -36,6 +37,9 @@ export function useGameHistoryVm() { const { i18n, t } = useTranslation() const accessToken = useAuthStore((state) => state.accessToken) const authStatus = useAuthStore((state) => state.status) + const roundId = useGameRoundStore((state) => state.round.id) + const winningCellId = useGameRoundStore((state) => state.round.winningCellId) + const lastOpenedRoundRef = useRef(null) const query = useInfiniteQuery({ queryKey: ['game', 'bet-my-orders', accessToken], @@ -79,6 +83,47 @@ export function useGameHistoryVm() { [i18n.resolvedLanguage, query.data?.pages], ) + useEffect(() => { + const openedRoundKey = + winningCellId === null || roundId.length === 0 + ? null + : `${roundId}:${winningCellId}` + + if (openedRoundKey === null) { + return + } + + if (lastOpenedRoundRef.current === null) { + lastOpenedRoundRef.current = openedRoundKey + return + } + + if (lastOpenedRoundRef.current === openedRoundKey) { + return + } + + lastOpenedRoundRef.current = openedRoundKey + + if ( + authStatus !== 'authenticated' || + items.length >= GAME_HISTORY_PAGE_SIZE || + query.isFetching || + query.isLoading + ) { + return + } + + void query.refetch() + }, [ + authStatus, + items.length, + query.isFetching, + query.isLoading, + query.refetch, + roundId, + winningCellId, + ]) + return { emptyText: t('gameDesktop.history.empty'), endText: t('gameDesktop.history.end'), diff --git a/src/features/game/hooks/use-protocol-agreement.ts b/src/features/game/hooks/use-protocol-agreement.ts new file mode 100644 index 0000000..dd51c14 --- /dev/null +++ b/src/features/game/hooks/use-protocol-agreement.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react' +import { useAppPreferenceStore, useModalStore } from '@/store' + +export function useProtocolAgreement() { + const isHydrated = useAppPreferenceStore((state) => state.isHydrated) + const hasAcceptedProtocol = useAppPreferenceStore( + (state) => state.hasAcceptedProtocol, + ) + const setProtocolAccepted = useAppPreferenceStore( + (state) => state.setProtocolAccepted, + ) + const open = useModalStore((state) => state.modals.desktopProtocol) + const setModalOpen = useModalStore((state) => state.setModalOpen) + + useEffect(() => { + if (!isHydrated) { + return + } + + if (!hasAcceptedProtocol) { + setModalOpen('desktopProtocol', true) + return + } + + setModalOpen('desktopProtocol', false) + }, [hasAcceptedProtocol, isHydrated, setModalOpen]) + + const acceptProtocol = () => { + setProtocolAccepted(true) + setModalOpen('desktopProtocol', false) + } + + return { + acceptProtocol, + hasAcceptedProtocol, + isHydrated, + open, + } +} diff --git a/src/features/game/modal/desktop/desktop-language-modal.tsx b/src/features/game/modal/desktop/desktop-language-modal.tsx new file mode 100644 index 0000000..3ffe183 --- /dev/null +++ b/src/features/game/modal/desktop/desktop-language-modal.tsx @@ -0,0 +1,100 @@ +import { useTranslation } from 'react-i18next' +import { CenterModal } from '@/components/center-modal.tsx' +import { SmartImage } from '@/components/smart-image.tsx' +import { useAppLanguage } from '@/features/game/hooks/use-app-language' +import { cn } from '@/lib/utils' +import { useModalStore } from '@/store' + +function DesktopLanguageModal() { + const { t } = useTranslation() + const open = useModalStore((state) => state.modals.desktopLanguage) + const setModalOpen = useModalStore((state) => state.setModalOpen) + const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage() + + const handleClose = () => { + setModalOpen('desktopLanguage', false) + } + + const handleSelectLanguage = async ( + language: (typeof languageOptions)[number]['code'], + ) => { + await selectLanguage(language) + handleClose() + } + + return ( + + {t('language.label')} +
+ } + titleAlign="center" + className="h-design-560 w-design-620" + > +
+
+ {languageOptions.map((option: (typeof languageOptions)[number]) => { + const isActive = option.code === currentLanguage + + return ( + + ) + })} +
+
+ + ) +} + +export default DesktopLanguageModal diff --git a/src/features/game/modal/desktop/desktop-protocol-modal.tsx b/src/features/game/modal/desktop/desktop-protocol-modal.tsx new file mode 100644 index 0000000..beb4304 --- /dev/null +++ b/src/features/game/modal/desktop/desktop-protocol-modal.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import lengthBlueBtn from '@/assets/system/length-blue-btn.webp' +import rightImg from '@/assets/system/right.webp' +import { CenterModal } from '@/components/center-modal.tsx' +import { SmartBackground } from '@/components/smart-background.tsx' +import { SmartImage } from '@/components/smart-image.tsx' +import { useProtocolAgreement } from '@/features/game/hooks/use-protocol-agreement' + +function DesktopProtocolModal() { + const { t } = useTranslation() + const { acceptProtocol, isHydrated, open } = useProtocolAgreement() + const [isChecked, setIsChecked] = useState(false) + + if (!isHydrated) { + return null + } + + return ( + + {t('game.modals.protocol.title')} +
+ } + titleAlign="center" + isShowClose={false} + className={'w-design-980 h-design-680'} + > +
+
+
+ {t('game.modals.protocol.content')} +
+
+ + + +
+ + {t('game.modals.protocol.confirm')} + +
+
+ + ) +} + +export default DesktopProtocolModal diff --git a/src/features/game/modal/desktop/desktop-rules-modal.tsx b/src/features/game/modal/desktop/desktop-rules-modal.tsx new file mode 100644 index 0000000..9806000 --- /dev/null +++ b/src/features/game/modal/desktop/desktop-rules-modal.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next' +import lengthBlueBtn from '@/assets/system/length-blue-btn.webp' +import { CenterModal } from '@/components/center-modal.tsx' +import { SmartBackground } from '@/components/smart-background.tsx' +import { useModalStore } from '@/store' + +function DesktopRulesModal() { + const { t } = useTranslation() + const open = useModalStore((state) => state.modals.desktopRules) + const setModalOpen = useModalStore((state) => state.setModalOpen) + + const handleClose = () => { + setModalOpen('desktopRules', false) + } + + return ( + + {t('game.modals.rules.title')} + + } + titleAlign="center" + className={'w-design-1040 h-design-720'} + > +
+
+ {t('game.modals.rules.content')} +
+ +
+ + {t('game.modals.rules.confirm')} + +
+
+
+ ) +} + +export default DesktopRulesModal diff --git a/src/features/game/shared/index.ts b/src/features/game/shared/index.ts index 5f7709f..9ae7bc7 100644 --- a/src/features/game/shared/index.ts +++ b/src/features/game/shared/index.ts @@ -1,4 +1,4 @@ export * from './constants' -export * from './mock-data' +export * from './initial-state' export * from './selectors' export * from './types' diff --git a/src/features/game/shared/initial-state.ts b/src/features/game/shared/initial-state.ts new file mode 100644 index 0000000..389a571 --- /dev/null +++ b/src/features/game/shared/initial-state.ts @@ -0,0 +1,81 @@ +import { DEFAULT_CHIP_AMOUNTS } from '@/constants' +import { DEFAULT_GAME_CHIP_COLORS, GAME_MAX_SELECTION_CELLS } from './constants' +import type { + AnnouncementState, + Chip, + ConnectionState, + DashboardState, + GameBootstrapSnapshot, + RoundSnapshot, +} from './types' + +function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot { + return { + bettingClosesAt: nowIso, + id: '', + phase: 'waiting', + revealingAt: nowIso, + settledAt: null, + startedAt: nowIso, + winningCellId: null, + } +} + +function createEmptyAnnouncementState(): AnnouncementState { + return { + activeAnnouncementId: null, + items: [], + lastUpdatedAt: null, + } +} + +function createEmptyConnectionState(): ConnectionState { + return { + connectedAt: null, + lastError: null, + lastMessageAt: null, + latencyMs: null, + reconnectAttempt: 0, + status: 'idle', + transport: 'offline', + } +} + +function createEmptyDashboardState(nowIso: string): DashboardState { + return { + countdownMs: 0, + featuredCellId: null, + onlinePlayers: 0, + tableLimitMax: 0, + tableLimitMin: 0, + totalPoolAmount: 0, + updatedAt: nowIso, + } +} + +function createDefaultChips(): Chip[] { + return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({ + amount: chip.amount, + color: DEFAULT_GAME_CHIP_COLORS[index] ?? DEFAULT_GAME_CHIP_COLORS[0], + id: chip.id, + isDefault: chip.id === 'chip-5', + label: String(chip.amount), + })) +} + +export function createEmptyGameBootstrapSnapshot( + nowIso = new Date().toISOString(), +): GameBootstrapSnapshot { + return { + announcements: createEmptyAnnouncementState(), + cells: [], + chips: createDefaultChips(), + connection: createEmptyConnectionState(), + dashboard: createEmptyDashboardState(nowIso), + history: [], + maxSelectionCount: GAME_MAX_SELECTION_CELLS, + round: createEmptyRoundSnapshot(nowIso), + selections: [], + trends: [], + } +} diff --git a/src/features/game/shared/mock-data.ts b/src/features/game/shared/mock-data.ts deleted file mode 100644 index f3052de..0000000 --- a/src/features/game/shared/mock-data.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { DEFAULT_CHIP_AMOUNTS } from '@/constants' -import { - DEFAULT_ACTIVE_CHIP_ID, - DEFAULT_ANNOUNCEMENT_TTL_MS, - DEFAULT_GAME_CHIP_COLORS, - GAME_GRID_COLUMNS, - GAME_MAX_SELECTION_CELLS, - GAME_TOTAL_CELLS, -} from './constants' -import { deriveTrendEntries, getRoundCountdownMs } from './selectors' -import type { - AnnouncementState, - BetSelection, - Chip, - ConnectionState, - DashboardState, - GameBootstrapSnapshot, - GameCell, - HistoryEntry, - RoundSnapshot, -} from './types' - -const MOCK_GAME_BASE_TIME = '2026-04-23T12:00:00.000Z' -const MOCK_HISTORY_RESULTS = [8, 12, 12, 4, 31, 9, 17, 22, 17, 5, 28, 13] - -function offsetIso(baseIso: string, offsetMs: number) { - return new Date(Date.parse(baseIso) + offsetMs).toISOString() -} - -export function createGameCells() { - return Array.from({ length: GAME_TOTAL_CELLS }, (_, index) => { - const id = index + 1 - - return { - column: (index % GAME_GRID_COLUMNS) + 1, - id, - label: String(id).padStart(2, '0'), - odds: 36, - row: Math.floor(index / GAME_GRID_COLUMNS) + 1, - } satisfies GameCell - }) -} - -export function createDefaultChips() { - return DEFAULT_CHIP_AMOUNTS.map((chip, index) => ({ - amount: chip.amount, - color: DEFAULT_GAME_CHIP_COLORS[index], - id: chip.id, - isDefault: chip.id === DEFAULT_ACTIVE_CHIP_ID, - label: chip.amount >= 100 ? `${chip.amount / 100}x` : String(chip.amount), - })) satisfies Chip[] -} - -export function createMockHistoryEntries(baseIso = MOCK_GAME_BASE_TIME) { - return MOCK_HISTORY_RESULTS.map((winningCellId, index) => { - const settledAt = offsetIso(baseIso, -(index + 1) * 30_000) - - return { - payoutMultiplier: 36, - roundId: `round-${6200 - index}`, - settledAt, - totalPoolAmount: 12_000 + index * 850, - winningCellId, - } satisfies HistoryEntry - }) -} - -export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) { - return { - bettingClosesAt: offsetIso(baseIso, 18_000), - id: 'round-6201', - phase: 'betting', - revealingAt: offsetIso(baseIso, 24_000), - settledAt: offsetIso(baseIso, 30_000), - startedAt: baseIso, - winningCellId: null, - } satisfies RoundSnapshot -} - -export function createMockBetSelections() { - return [] satisfies BetSelection[] -} - -export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) { - return { - activeAnnouncementId: 'announcement-maintenance', - items: [ - { - createdAt: offsetIso(baseIso, -20_000), - expiresAt: offsetIso(baseIso, DEFAULT_ANNOUNCEMENT_TTL_MS), - id: 'announcement-maintenance', - isPinned: true, - isRead: false, - message: 'Realtime sync upgrades finish after the current cycle.', - title: 'Table maintenance', - tone: 'warning', - }, - { - createdAt: offsetIso(baseIso, -55_000), - expiresAt: null, - id: 'announcement-promo', - isRead: true, - message: 'Warm-up round rebates are credited every 5 settled rounds.', - title: 'Reward window live', - tone: 'success', - }, - ], - lastUpdatedAt: offsetIso(baseIso, -10_000), - } satisfies AnnouncementState -} - -export function createMockDashboardState( - baseIso = MOCK_GAME_BASE_TIME, - round = createMockRoundSnapshot(baseIso), - history = createMockHistoryEntries(baseIso), -) { - return { - countdownMs: getRoundCountdownMs(round, baseIso), - featuredCellId: history[0]?.winningCellId ?? null, - onlinePlayers: 1_284, - tableLimitMax: 5_000, - tableLimitMin: 10, - totalPoolAmount: 84_300, - updatedAt: baseIso, - } satisfies DashboardState -} - -export function createMockConnectionState(baseIso = MOCK_GAME_BASE_TIME) { - return { - connectedAt: offsetIso(baseIso, -180_000), - lastError: null, - lastMessageAt: offsetIso(baseIso, -500), - latencyMs: 48, - reconnectAttempt: 0, - status: 'connected', - transport: 'websocket', - } satisfies ConnectionState -} - -export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) { - const cells = createGameCells() - const chips = createDefaultChips() - const history = createMockHistoryEntries(baseIso) - const round = createMockRoundSnapshot(baseIso) - - return { - announcements: createMockAnnouncementState(baseIso), - cells, - chips, - connection: createMockConnectionState(baseIso), - dashboard: createMockDashboardState(baseIso, round, history), - history, - maxSelectionCount: GAME_MAX_SELECTION_CELLS, - round, - selections: createMockBetSelections(), - trends: deriveTrendEntries(history), - } satisfies GameBootstrapSnapshot -} diff --git a/src/lib/api/api-client.ts b/src/lib/api/api-client.ts index 6d8cabb..fe4303c 100644 --- a/src/lib/api/api-client.ts +++ b/src/lib/api/api-client.ts @@ -36,6 +36,10 @@ function normalizeApiBaseUrl(baseUrl: string | undefined) { throw new Error('VITE_API_BASE_URL 未配置') } + if (candidate === '/') { + return '/' + } + if (/^https?:\/\//.test(candidate)) { return candidate.replace(/\/+$/, '') } diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index 106c2a1..a6c633a 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -124,6 +124,19 @@ export default { 'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.', check: 'View', }, + protocol: { + title: 'User Agreement', + content: + 'Welcome to the 36-Character Flower game lobby.\n\nBefore entering the site, please read and confirm the following:\n1. You have reached the legal age required in your region.\n2. You understand the current content is only for use within this account and this site, and must not be copied, redistributed, or used for unlawful purposes.\n3. You agree to follow the site rules regarding account usage, top-up, withdrawal, risk control, and gameplay.\n4. By continuing into the game lobby, you acknowledge and accept the relevant service terms and data handling rules.\n\nPlease check the agreement to continue.', + agreeLabel: 'I have read and agree to the User Agreement', + confirm: 'Agree and Enter', + }, + rules: { + title: 'Game Rules', + content: + '1. Basic Gameplay\n1) After each round starts, players may select one or more numbers on the board to place bets.\n2) After betting closes, the system enters the draw phase and settles rewards based on the round result.\n3) Different chip levels correspond to different bet amounts, subject to the current table limits and configuration.\n\n2. Betting Notes\n1) Bets can only be submitted during the betting phase.\n2) Before confirming, please verify your selected numbers, chip amount, and total bet.\n3) If your balance is insufficient, the round is no longer valid, or betting has closed, the request will be rejected.\n\n3. Draw and Settlement\n1) The final displayed draw result is the valid outcome.\n2) Hit rules, odds, payouts, and streak performance are settled in real time according to the current room configuration.\n3) In case of network fluctuation, please refer to the re-synced official data.\n\n4. Additional Notes\n1) Please manage your play time responsibly.\n2) Any abnormal behavior intended to interfere with the system, exploit rewards, or bypass risk control is strictly prohibited.\n3) The platform reserves the right to review orders, payouts, and account status in exceptional situations.', + confirm: 'Understood', + }, procedures: { title: 'Top Up / Withdraw', contentPlaceholder: 'Choose the action you want to continue with', @@ -178,7 +191,7 @@ export default { implementationBody: 'Next steps are the real API, WebSocket, full UI store, and round lifecycle state machine.', limitsTitle: 'Table limits', - limitsSubtitle: 'Derived from dashboard mock data', + limitsSubtitle: 'Derived from the current lobby data', minBet: 'Min bet', maxBet: 'Max bet', }, @@ -193,6 +206,15 @@ export default { loginRequired: 'Please log in before entering the game', loginSuccess: 'Login successful', registerSuccess: 'Registration successful', + insufficientBalance: 'Insufficient balance. Please adjust your bet.', + betUnavailable: 'Betting is not available for this round', + betPlaced: 'Bet placed successfully', + noRecentSuccessfulBet: + 'No successful bet from the previous round was found', + repeatSelectionsRestored: + 'Selections from the last successful round have been restored', + betRejected: 'Bet was not accepted', + betPlaceFailed: 'Failed to place the bet. Please try again.', }, }, auth: { @@ -299,6 +321,9 @@ export default { selected: 'Selected', totalBet: 'Total Bet', confirm: 'Confirm', + selectNumbers: 'Select Numbers', + insufficientBalance: 'Insufficient Balance', + submitting: 'Submitting...', actions: { clear: 'Clear', repeat: 'Repeat', diff --git a/src/locales/id-ID/common.ts b/src/locales/id-ID/common.ts index c0b8824..1da1745 100644 --- a/src/locales/id-ID/common.ts +++ b/src/locales/id-ID/common.ts @@ -123,6 +123,19 @@ export default { 'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.', check: 'Lihat', }, + protocol: { + title: 'Perjanjian Pengguna', + content: + 'Selamat datang di lobi game 36-Character Flower.\n\nSebelum masuk ke situs, mohon baca dan konfirmasi hal berikut:\n1. Kamu telah mencapai usia legal yang diwajibkan di wilayahmu.\n2. Kamu memahami bahwa konten saat ini hanya untuk penggunaan pada akun dan situs ini, serta tidak boleh disalin, disebarkan, atau digunakan untuk tujuan yang melanggar hukum.\n3. Kamu setuju untuk mematuhi aturan situs terkait akun, isi ulang, penarikan, kontrol risiko, dan permainan.\n4. Dengan melanjutkan ke lobi game, kamu menyatakan telah mengetahui dan menerima ketentuan layanan serta aturan pemrosesan data yang berlaku.\n\nSilakan centang persetujuan untuk melanjutkan.', + agreeLabel: 'Saya telah membaca dan menyetujui Perjanjian Pengguna', + confirm: 'Setuju dan Masuk', + }, + rules: { + title: 'Aturan Permainan', + content: + '1. Gameplay Dasar\n1) Setelah setiap ronde dimulai, pemain dapat memilih satu atau beberapa angka di papan untuk memasang taruhan.\n2) Setelah taruhan ditutup, sistem masuk ke fase undian dan menyelesaikan hadiah berdasarkan hasil ronde.\n3) Level chip yang berbeda mewakili jumlah taruhan yang berbeda, mengikuti batas meja dan konfigurasi saat ini.\n\n2. Catatan Taruhan\n1) Taruhan hanya bisa dikirim saat fase taruhan berlangsung.\n2) Sebelum konfirmasi, periksa kembali angka yang dipilih, nominal chip, dan total taruhan.\n3) Jika saldo tidak cukup, ronde tidak lagi valid, atau taruhan sudah ditutup, permintaan akan ditolak.\n\n3. Undian dan Penyelesaian\n1) Hasil undian akhir yang ditampilkan sistem adalah hasil yang berlaku.\n2) Aturan kena, odds, pembayaran, dan performa streak diselesaikan secara real time sesuai konfigurasi room saat ini.\n3) Jika terjadi gangguan jaringan, silakan mengacu pada data resmi setelah sinkronisasi ulang.\n\n4. Catatan Tambahan\n1) Mohon atur waktu bermain secara bertanggung jawab.\n2) Segala tindakan tidak wajar untuk mengganggu sistem, mengeksploitasi hadiah, atau menghindari kontrol risiko dilarang keras.\n3) Platform berhak meninjau pesanan, pembayaran, dan status akun dalam kondisi khusus.', + confirm: 'Saya Mengerti', + }, procedures: { title: 'Isi Ulang / Tarik Dana', contentPlaceholder: 'Pilih tindakan yang ingin kamu lanjutkan', @@ -177,7 +190,7 @@ export default { implementationBody: 'Langkah berikutnya adalah API nyata, WebSocket, UI store penuh, dan state machine siklus ronde.', limitsTitle: 'Batas meja', - limitsSubtitle: 'Berasal dari data mock dashboard', + limitsSubtitle: 'Berasal dari data lobby saat ini', minBet: 'Bet minimum', maxBet: 'Bet maksimum', }, @@ -192,6 +205,15 @@ export default { loginRequired: 'Silakan masuk sebelum memasuki game', loginSuccess: 'Berhasil masuk', registerSuccess: 'Pendaftaran berhasil', + insufficientBalance: 'Saldo tidak cukup. Silakan sesuaikan taruhan.', + betUnavailable: 'Taruhan tidak tersedia untuk ronde ini', + betPlaced: 'Taruhan berhasil dikirim', + noRecentSuccessfulBet: + 'Tidak ada riwayat taruhan berhasil dari ronde sebelumnya', + repeatSelectionsRestored: + 'Pilihan dari ronde berhasil terakhir telah dipulihkan', + betRejected: 'Taruhan tidak diterima', + betPlaceFailed: 'Gagal mengirim taruhan. Silakan coba lagi.', }, }, auth: { @@ -298,6 +320,9 @@ export default { selected: 'Dipilih', totalBet: 'Total Bet', confirm: 'Konfirmasi', + selectNumbers: 'Pilih Nombor', + insufficientBalance: 'Saldo Tidak Cukup', + submitting: 'Mengirim...', actions: { clear: 'Hapus', repeat: 'Ulang', diff --git a/src/locales/ms-MY/common.ts b/src/locales/ms-MY/common.ts index 947d628..8aeae9d 100644 --- a/src/locales/ms-MY/common.ts +++ b/src/locales/ms-MY/common.ts @@ -126,6 +126,20 @@ export default { 'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.', check: 'Semak', }, + protocol: { + title: 'Perjanjian Pengguna', + content: + 'Selamat datang ke lobi permainan 36-Character Flower.\n\nSebelum memasuki laman ini, sila baca dan sahkan perkara berikut:\n1. Anda telah mencapai umur sah yang ditetapkan di kawasan anda.\n2. Anda memahami bahawa kandungan semasa hanya untuk digunakan dalam akaun dan laman ini, dan tidak boleh disalin, diedarkan semula, atau digunakan untuk tujuan yang menyalahi undang-undang.\n3. Anda bersetuju untuk mematuhi peraturan laman berkaitan akaun, tambah nilai, pengeluaran, kawalan risiko, dan permainan.\n4. Dengan meneruskan ke lobi permainan, anda mengakui dan menerima terma perkhidmatan serta peraturan pemprosesan data yang berkaitan.\n\nSila tandakan persetujuan untuk meneruskan.', + agreeLabel: + 'Saya telah membaca dan bersetuju dengan Perjanjian Pengguna', + confirm: 'Setuju dan Masuk', + }, + rules: { + title: 'Peraturan Permainan', + content: + '1. Permainan Asas\n1) Selepas setiap pusingan bermula, pemain boleh memilih satu atau beberapa nombor pada papan untuk membuat taruhan.\n2) Selepas taruhan ditutup, sistem memasuki fasa cabutan dan menyelesaikan ganjaran berdasarkan keputusan pusingan.\n3) Tahap cip yang berbeza mewakili jumlah taruhan yang berbeza, tertakluk kepada had meja dan konfigurasi semasa.\n\n2. Nota Taruhan\n1) Taruhan hanya boleh dihantar semasa fasa taruhan.\n2) Sebelum mengesahkan, sila semak nombor yang dipilih, jumlah cip, dan jumlah taruhan keseluruhan.\n3) Jika baki tidak mencukupi, pusingan tidak lagi sah, atau taruhan telah ditutup, permintaan akan ditolak.\n\n3. Cabutan dan Penyelesaian\n1) Keputusan cabutan akhir yang dipaparkan sistem ialah keputusan yang sah.\n2) Peraturan kena, odds, bayaran, dan prestasi streak diselesaikan secara masa nyata mengikut konfigurasi bilik semasa.\n3) Jika berlaku gangguan rangkaian, sila rujuk data rasmi selepas penyegerakan semula.\n\n4. Nota Tambahan\n1) Sila urus masa permainan anda dengan bertanggungjawab.\n2) Sebarang tingkah laku tidak normal untuk mengganggu sistem, mengeksploitasi ganjaran, atau memintas kawalan risiko adalah dilarang sama sekali.\n3) Platform berhak menyemak pesanan, bayaran, dan status akaun dalam keadaan khas.', + confirm: 'Saya Faham', + }, procedures: { title: 'Tambah Nilai / Pengeluaran', contentPlaceholder: 'Pilih tindakan yang ingin anda teruskan', @@ -180,7 +194,7 @@ export default { implementationBody: 'Langkah seterusnya ialah API sebenar, WebSocket, UI store penuh, dan state machine kitaran pusingan.', limitsTitle: 'Had meja', - limitsSubtitle: 'Diambil daripada data mock dashboard', + limitsSubtitle: 'Diambil daripada data lobi semasa', minBet: 'Taruhan minimum', maxBet: 'Taruhan maksimum', }, @@ -195,6 +209,15 @@ export default { loginRequired: 'Sila log masuk sebelum memasuki permainan', loginSuccess: 'Log masuk berjaya', registerSuccess: 'Pendaftaran berjaya', + insufficientBalance: 'Baki tidak mencukupi. Sila laraskan taruhan.', + betUnavailable: 'Taruhan tidak tersedia untuk pusingan ini', + betPlaced: 'Taruhan berjaya dihantar', + noRecentSuccessfulBet: + 'Tiada rekod taruhan berjaya untuk pusingan sebelumnya', + repeatSelectionsRestored: + 'Pilihan dari pusingan berjaya terakhir telah dipulihkan', + betRejected: 'Taruhan tidak diterima', + betPlaceFailed: 'Gagal menghantar taruhan. Sila cuba lagi.', }, }, auth: { @@ -301,6 +324,9 @@ export default { selected: 'Dipilih', totalBet: 'Jumlah Taruhan', confirm: 'Sahkan', + selectNumbers: 'Pilih Nombor', + insufficientBalance: 'Baki Tidak Mencukupi', + submitting: 'Menghantar...', actions: { clear: 'Kosongkan', repeat: 'Ulang', diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index c5eb2da..2c89542 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -121,6 +121,19 @@ export default { '这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。', check: '查看', }, + protocol: { + title: '用户协议', + content: + '欢迎进入 36 字花游戏大厅。\n\n进入站点前,请先阅读并确认以下协议内容:\n1. 你已年满所在地区法律要求的法定年龄。\n2. 你理解当前展示内容仅限当前账号与当前站点使用,不得擅自复制、转发或用于非法用途。\n3. 你同意遵守站点的账户、充值、提现、风控与游戏规则说明。\n4. 若你继续进入游戏大厅,即表示你已知悉并接受相关服务条款与数据处理规则。\n\n请勾选同意后继续进入游戏界面。', + agreeLabel: '我已阅读并同意《用户协议》', + confirm: '同意并进入', + }, + rules: { + title: '玩法规则', + content: + '一、基础玩法\n1. 每一局开始后,玩家可以在盘面上选择一个或多个号码进行下注。\n2. 系统会在封盘后进入开奖阶段,并根据当期结果结算对应奖金。\n3. 不同下注档位对应不同下注金额,实际以当前系统配置和桌限为准。\n\n二、下注说明\n1. 只有在“下注中”阶段可以提交下注。\n2. 每次提交前,请确认已选号码、下注金额和总下注额。\n3. 若余额不足、期号失效或已封盘,系统将拒绝该次下注请求。\n\n三、开奖与结算\n1. 开奖结果以系统最终展示为准。\n2. 命中规则、赔率、派彩与连中表现按当前房间配置实时结算。\n3. 如遇网络波动,请以重新同步后的官方数据为准。\n\n四、其他说明\n1. 请合理安排游戏时间,理性参与。\n2. 严禁使用任何异常手段干扰系统、刷取奖励或规避风控。\n3. 平台保留在异常情况下对订单、派奖和账户状态进行复核的权利。', + confirm: '我知道了', + }, procedures: { title: '充值 / 提现', contentPlaceholder: '请选择你要进行的操作', @@ -172,7 +185,7 @@ export default { implementationBody: '下一步会继续接入真实 API、WebSocket、完整 UI Store 和回合状态机。', limitsTitle: '桌限信息', - limitsSubtitle: '来自 dashboard mock 数据', + limitsSubtitle: '来自当前大厅数据', minBet: '最低下注', maxBet: '最高下注', }, @@ -187,6 +200,13 @@ export default { loginRequired: '请先登录后进入游戏', loginSuccess: '登录成功', registerSuccess: '注册成功', + insufficientBalance: '余额不足,请调整下注金额', + betUnavailable: '当前期不可下注', + betPlaced: '下注成功', + noRecentSuccessfulBet: '暂无上一局成功下注记录', + repeatSelectionsRestored: '已恢复上一局成功下注的花字', + betRejected: '下注未受理', + betPlaceFailed: '下注失败,请稍后重试', }, }, auth: { @@ -291,6 +311,9 @@ export default { selected: '已选', totalBet: '总下注', confirm: '确认', + selectNumbers: '请选择号码', + insufficientBalance: '余额不足', + submitting: '提交中...', actions: { clear: '清空', repeat: '重复', diff --git a/src/main.tsx b/src/main.tsx index 72839f9..a5a6b70 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import { getCurrentUserProfile, refreshAuthSession, } from '@/features/auth/api/auth-api' +import { GlobalAudioController } from '@/features/game/audio/global-audio-controller' import '@/i18n' import { prefetchAuthToken } from '@/lib/api/api-client' import { @@ -43,6 +44,7 @@ void initializeAuthSession().then(async () => { createRoot(rootElement).render( + {shouldShowQueryDevtools && } diff --git a/src/store/audio/audio-store.ts b/src/store/audio/audio-store.ts new file mode 100644 index 0000000..7d1528c --- /dev/null +++ b/src/store/audio/audio-store.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +import { AUDIO_PREFERENCES_STORAGE_KEY } from '@/constants' + +interface AudioPreferenceState { + hasUnlockedSoundPlayback: boolean + markSoundPlaybackUnlocked: () => void + isSoundEnabled: boolean + setSoundEnabled: (enabled: boolean) => void + toggleSoundEnabled: () => void +} + +export const useAudioStore = create()( + persist( + (set) => ({ + hasUnlockedSoundPlayback: false, + isSoundEnabled: true, + markSoundPlaybackUnlocked: () => { + set({ hasUnlockedSoundPlayback: true }) + }, + setSoundEnabled: (enabled) => { + set({ isSoundEnabled: enabled }) + }, + toggleSoundEnabled: () => { + set((state) => ({ isSoundEnabled: !state.isSoundEnabled })) + }, + }), + { + name: AUDIO_PREFERENCES_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + merge: (persistedState, currentState) => ({ + ...currentState, + ...(persistedState as Partial), + hasUnlockedSoundPlayback: false, + }), + partialize: (state) => ({ + isSoundEnabled: state.isSoundEnabled, + }), + }, + ), +) diff --git a/src/store/audio/index.ts b/src/store/audio/index.ts new file mode 100644 index 0000000..9a2cda5 --- /dev/null +++ b/src/store/audio/index.ts @@ -0,0 +1 @@ +export * from './audio-store' diff --git a/src/store/auth/auth-store.ts b/src/store/auth/auth-store.ts index ee3ef42..dd11049 100644 --- a/src/store/auth/auth-store.ts +++ b/src/store/auth/auth-store.ts @@ -50,6 +50,7 @@ interface PersistedAuthState { interface PersistedAppPreferenceState { appLanguage: string | null deviceId: string | null + hasAcceptedProtocol: boolean } interface AuthState extends PersistedAuthState { @@ -217,8 +218,11 @@ export const useAuthStore = create()( ) interface AppPreferenceStoreState extends PersistedAppPreferenceState { + finishHydration: () => void getOrCreateDeviceId: () => string + isHydrated: boolean setAppLanguage: (language: string) => void + setProtocolAccepted: (accepted: boolean) => void } export const useAppPreferenceStore = create()( @@ -226,6 +230,11 @@ export const useAppPreferenceStore = create()( (set, get) => ({ appLanguage: null, deviceId: null, + hasAcceptedProtocol: false, + isHydrated: false, + finishHydration: () => { + set({ isHydrated: true }) + }, getOrCreateDeviceId: () => { const deviceId = get().deviceId @@ -242,6 +251,9 @@ export const useAppPreferenceStore = create()( setAppLanguage: (language) => { set({ appLanguage: language }) }, + setProtocolAccepted: (accepted) => { + set({ hasAcceptedProtocol: accepted }) + }, }), { name: APP_PREFERENCES_STORAGE_KEY, @@ -249,7 +261,11 @@ export const useAppPreferenceStore = create()( partialize: (state) => ({ appLanguage: state.appLanguage, deviceId: state.deviceId, + hasAcceptedProtocol: state.hasAcceptedProtocol, }), + onRehydrateStorage: () => (state) => { + state?.finishHydration() + }, }, ), ) @@ -265,3 +281,11 @@ export function getStoredAppLanguage() { export function setStoredAppLanguage(language: string) { useAppPreferenceStore.getState().setAppLanguage(language) } + +export function getStoredProtocolAccepted() { + return useAppPreferenceStore.getState().hasAcceptedProtocol +} + +export function setStoredProtocolAccepted(accepted: boolean) { + useAppPreferenceStore.getState().setProtocolAccepted(accepted) +} diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts index d339df6..b85bff8 100644 --- a/src/store/game/game-round-store.ts +++ b/src/store/game/game-round-store.ts @@ -12,7 +12,7 @@ import type { } from '@/features/game/shared' import { buildGameCellViewModels, - createMockGameBootstrapSnapshot, + createEmptyGameBootstrapSnapshot, DEFAULT_ACTIVE_CHIP_ID, getChipById, getRecentWinningCellIds, @@ -31,29 +31,54 @@ type GameRoundSlice = Pick< | 'trends' > +function resolveRecentActiveChipId( + chips: Chip[], + selections: BetSelection[], + fallbackChipId: string, +) { + for (let index = selections.length - 1; index >= 0; index -= 1) { + const chipId = selections[index]?.chipId + + if (chipId && getChipById(chips, chipId)) { + return chipId + } + } + + return getChipById(chips, fallbackChipId) + ? fallbackChipId + : (chips.find((chip) => chip.isDefault)?.id ?? + chips[0]?.id ?? + DEFAULT_ACTIVE_CHIP_ID) +} + export interface GameRoundStoreState extends GameRoundSlice { activeChipId: string clearSelections: () => void hydrateRound: (snapshot: GameRoundSlice) => void placeBet: (cellId: number) => void + recentSuccessfulSelections: BetSelection[] removeSelectionsForCell: (cellId: number) => void + restoreRecentSuccessfulSelections: () => boolean + setRecentSuccessfulSelections: (selections: BetSelection[]) => void selectChip: (chipId: string) => void setPhase: (phase: RoundPhase) => void syncRound: (round: Partial) => void upsertSelections: (selections: BetSelection[]) => void } -function createInitialRoundState(): GameRoundSlice & { activeChipId: string } { - const snapshot = createMockGameBootstrapSnapshot() +function createInitialRoundState(): GameRoundSlice & { + activeChipId: string + recentSuccessfulSelections: BetSelection[] +} { + const snapshot = createEmptyGameBootstrapSnapshot() return { - activeChipId: - snapshot.chips.find((chip) => chip.isDefault)?.id ?? - DEFAULT_ACTIVE_CHIP_ID, + activeChipId: DEFAULT_ACTIVE_CHIP_ID, cells: snapshot.cells, chips: snapshot.chips, history: snapshot.history, maxSelectionCount: snapshot.maxSelectionCount, + recentSuccessfulSelections: [], round: snapshot.round, selections: snapshot.selections, trends: snapshot.trends, @@ -118,6 +143,7 @@ export const useGameRoundStore = create()((set) => ({ } }) }, + recentSuccessfulSelections: [], removeSelectionsForCell: (cellId) => { set((state) => ({ selections: state.selections.filter( @@ -125,6 +151,48 @@ export const useGameRoundStore = create()((set) => ({ ), })) }, + restoreRecentSuccessfulSelections: () => { + const state = useGameRoundStore.getState() + + if ( + state.round.phase !== 'betting' || + state.recentSuccessfulSelections.length === 0 + ) { + return false + } + + const nextSelections = state.recentSuccessfulSelections + .filter((selection) => getChipById(state.chips, selection.chipId)) + .slice(0, state.maxSelectionCount) + .map((selection, index) => ({ + ...selection, + id: `bet-repeat-${selection.cellId}-${index + 1}-${Date.now()}`, + placedAt: new Date().toISOString(), + source: 'local' as const, + })) + + if (nextSelections.length === 0) { + return false + } + + set({ + activeChipId: resolveRecentActiveChipId( + state.chips, + nextSelections, + state.activeChipId, + ), + selections: nextSelections, + }) + + return true + }, + setRecentSuccessfulSelections: (selections) => { + set({ + recentSuccessfulSelections: selections.map((selection) => ({ + ...selection, + })), + }) + }, selectChip: (chipId) => { set((state) => { if (!getChipById(state.chips, chipId)) { diff --git a/src/store/game/game-session-store.ts b/src/store/game/game-session-store.ts index 844c4c3..da1f736 100644 --- a/src/store/game/game-session-store.ts +++ b/src/store/game/game-session-store.ts @@ -8,7 +8,7 @@ import type { GameBootstrapSnapshot, } from '@/features/game/shared' import { - createMockGameBootstrapSnapshot, + createEmptyGameBootstrapSnapshot, getUnreadAnnouncementCount, getVisibleAnnouncements, } from '@/features/game/shared' @@ -32,7 +32,7 @@ export interface GameSessionStoreState extends GameSessionSlice { } function createInitialSessionState(): GameSessionSlice { - const snapshot = createMockGameBootstrapSnapshot() + const snapshot = createEmptyGameBootstrapSnapshot() return { announcements: snapshot.announcements, diff --git a/src/store/index.ts b/src/store/index.ts index 1a1232f..ef5f9f4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,3 +1,4 @@ +export * from './audio' export * from './auth' export * from './game' export * from './modal' diff --git a/src/store/modal/modal-store.ts b/src/store/modal/modal-store.ts index 94346ef..3e05e52 100644 --- a/src/store/modal/modal-store.ts +++ b/src/store/modal/modal-store.ts @@ -6,6 +6,12 @@ export const MODAL_KEYS = [ 'desktopLogin', /**@description 桌面端注册弹窗*/ 'desktopRegister', + /**@description 桌面端多语言弹窗*/ + 'desktopLanguage', + /**@description 桌面端协议弹窗*/ + 'desktopProtocol', + /**@description 桌面端规则弹窗*/ + 'desktopRules', /**@description 桌面端用户信息弹窗*/ 'desktopUserInfo', /**@description 桌面端公告弹窗*/ @@ -25,6 +31,9 @@ type ModalVisibilityMap = Record const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = { desktopLogin: false, desktopRegister: false, + desktopLanguage: false, + desktopProtocol: false, + desktopRules: false, desktopUserInfo: false, desktopNotice: false, desktopAutoSetting: false,