feat(game): 添加游戏大厅音频控制和用户协议功能

- 实现音频资源配置和音频商店状态管理
- 添加用户协议和游戏规则的多语言支持
- 集成音频播放解锁机制和声音开关功能
- 更新API客户端以支持根路径候选
- 优化游戏历史记录组件的滚动加载逻辑
- 添加桌面端控制按钮的动画效果和交互反馈
- 实现语言切换和音效控制的UI组件
- 增加下注相关的状态管理和错误提示
- 完善应用偏好设置的存储和持久化逻辑
This commit is contained in:
JiaJun
2026-05-16 18:02:59 +08:00
parent 5dd4e31db4
commit 85b4d9481f
46 changed files with 1500 additions and 362 deletions

View File

@@ -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<RoundSnapshot, 'id' | 'startedAt'> | 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<GamePlaceBetDto>(
GAME_API_ENDPOINTS.placeBet,
{
json: payload,
},
)
const dto = unwrapGameEnvelope(
response as ApiResponse<GamePlaceBetDto>,
'Failed to place game bet',
)
return createMockGameBootstrapSnapshot()
return dto
}

View File

@@ -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,

View File

@@ -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,
},
]

View File

@@ -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<keyof WindowEventMap> = [
'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
}

View File

@@ -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()
}

View File

@@ -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 (
<div
@@ -363,14 +379,46 @@ export function DesktopControl() {
</SmartBackground>
<SmartBackground
as={motion.button}
src={confirmBg}
src={confirmState === 'insufficient' ? confirmRedBg : confirmBg}
size="100% 100%"
type="button"
onClick={handleConfirmClick}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.96 }}
whileHover={isConfirmClickable ? { scale: 1.01 } : undefined}
whileTap={isConfirmClickable ? { scale: 0.96 } : undefined}
animate={
confirmState === 'ready'
? {
scale: [1, 1.035, 1],
filter: [
'drop-shadow(0 0 0 rgba(0,0,0,0))',
'drop-shadow(0 0 18px rgba(245,200,107,0.85))',
'drop-shadow(0 0 0 rgba(0,0,0,0))',
],
}
: confirmState === 'idle'
? { scale: 1, filter: 'grayscale(.95)' }
: { scale: 1, filter: 'none' }
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.2, ease: 'easeOut' }
}
style={
confirmState === 'idle'
? {
WebkitFilter: 'grayscale(.95)',
filter: 'grayscale(.95)',
}
: undefined
}
className={cn(
'relative z-10 h-full w-design-260 shrink-0 cursor-pointer bg-center bg-no-repeat flex items-center justify-center text-design-32 font-bold',
'relative z-10 flex h-full w-design-260 shrink-0 items-center justify-center bg-center bg-no-repeat text-design-32 font-bold',
isConfirmClickable ? 'cursor-pointer' : 'cursor-not-allowed',
)}
>
{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%"
/>
)}
<motion.span
animate={confirmClicked ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="relative"
animate={
confirmState === 'ready'
? {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : [0, -1, 0],
textShadow: [
'0 0 12px rgba(255,238,173,0.72), 0 0 22px rgba(255,214,96,0.4)',
'0 0 18px rgba(255,252,220,0.95), 0 0 34px rgba(255,214,96,0.8)',
'0 0 12px rgba(255,238,173,0.72), 0 0 22px rgba(255,214,96,0.4)',
],
}
: {
opacity: confirmClicked ? 0 : 1,
y: confirmClicked ? 2 : 0,
textShadow:
confirmState === 'insufficient'
? '0 0 10px rgba(255,206,206,0.45)'
: 'none',
}
}
transition={
confirmState === 'ready'
? {
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}
: { duration: 0.15 }
}
className={cn(
'relative',
confirmState === 'insufficient' && 'text-[#FFF1F1]',
)}
>
{t('gameDesktop.control.confirm')}
{confirmLabel}
</motion.span>
</SmartBackground>
</div>

View File

@@ -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<HTMLDivElement | null>(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 (
<SmartBackground
@@ -65,6 +49,7 @@ export function DesktopGameHistory() {
</div>
<div
ref={parentRef}
onScroll={handleScroll}
className={
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
}
@@ -86,98 +71,80 @@ export function DesktopGameHistory() {
{emptyText}
</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<>
{items.map((item) => (
<div key={item.id} className="w-full pb-design-12 last:pb-0">
<div
key={item?.id ?? `loader-${virtualRow.index}`}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
{item ? (
<div
className={
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
}
>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.orderNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>
{item.periodNo}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</span>
<span>{item.numbersLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
<div
className={
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
}
>
{item.statusLabel}
</div>
<div
className={
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
}
>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.orderNo')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>{item.orderNo}</span>
</div>
) : (
<div className="flex h-[calc(var(--design-unit)*60)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : endText}
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.roundId')}:{' '}
</span>
<span className={'text-[#C0E7EB]'}>{item.periodNo}</span>
</div>
)}
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.numbers')}:{' '}
</span>
<span>{item.numbersLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.settledAt')}:{' '}
</span>
<span>{item.createdAtLabel}</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.totalPoolAmount')}:{' '}
</span>
<span className={'text-[#FFE375]'}>
{item.amountLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}:{' '}
</span>
<span className={'text-[#FF7575]'}>
{item.resultNumberLabel}
</span>
</div>
<div>
<span className={'text-[#84A2A2]'}>
{t('gameDesktop.history.payout')}:{' '}
</span>
<span>{item.winAmountLabel}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
<div className="flex min-h-[calc(var(--design-unit)*40)] items-center justify-center text-design-16 text-[#84A2A2]">
{isFetchingNextPage ? loadingText : hasNextPage ? '' : endText}
</div>
</>
)}
</div>
</SmartBackground>

View File

@@ -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() {
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<button
type="button"
onClick={() => setModalOpen('desktopRules', true)}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.rules')}</div>
</div>
</button>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Mail color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.message')}</div>
</div>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<Volume2 color={'#57B8BF'} size={16} />
<button
type="button"
onClick={toggleSoundEnabled}
className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85"
>
{isSoundEnabled ? (
<Volume2 color={'#57B8BF'} size={16} />
) : (
<VolumeX color={'#57B8BF'} size={16} />
)}
<div>{t('gameDesktop.header.bgm')}</div>
</div>
</button>
<div className="min-w-design-120 common-neon-inset flex cursor-pointer items-center justify-center gap-design-10 !px-design-16 transition-opacity hover:opacity-85">
<CircleAlert color={'#57B8BF'} size={16} />
<div>{t('gameDesktop.header.id')}</div>
<div className={'flex items-center justify-center'}>
<button
type="button"
onClick={() => setModalOpen('desktopLanguage', true)}
className={
'common-neon-inset text-design-16 !py-design-20 box-border flex h-design-36 w-fit items-center justify-between gap-design-8 !px-design-20 transition-opacity hover:opacity-85'
}
>
<div className="flex items-center gap-design-14">
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-24 w-design-24 rounded-[2px] object-cover"
/>
<div className="truncate">{currentLanguageLabel}</div>
</div>
</button>
</div>
<button

View File

@@ -1,2 +1,4 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'

View File

@@ -4,10 +4,13 @@ import { DesktopControl } from '@/features/game/components/desktop/desktop-contr
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopProtocolModal from '@/features/game/modal/desktop/desktop-protocol-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
@@ -43,12 +46,25 @@ export function PcEntry() {
>
<DesktopControl />
</div>
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
<DesktopLoginModal />
{/* 桌面端注册弹窗:用于新用户注册账号 */}
<DesktopRegisterModal />
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
<DesktopLanguageModal />
{/* 桌面端协议弹窗:首次进入站点时强制同意协议后才可继续 */}
<DesktopProtocolModal />
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
<DesktopRulesModal />
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
<DesktopUserInfoModal />
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
<DesktopNoticeModal />
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
<DesktopAutoSettingModal />
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
</>
)

View File

@@ -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,
}
}

View File

@@ -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<string, { betId: number; numbers: number[] }>
>((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,

View File

@@ -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<string | null>(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'),

View File

@@ -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,
}
}

View File

@@ -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 (
<CenterModal
open={open}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-30'}>
{t('language.label')}
</div>
}
titleAlign="center"
className="h-design-560 w-design-620"
>
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">
<div className="grid flex-1 grid-cols-2 gap-design-16">
{languageOptions.map((option: (typeof languageOptions)[number]) => {
const isActive = option.code === currentLanguage
return (
<button
key={option.code}
type="button"
onClick={() => void handleSelectLanguage(option.code)}
className={cn(
'group relative flex h-full min-h-design-150 w-full flex-col justify-between overflow-hidden rounded-[18px] border px-design-18 py-design-18 text-left transition-all duration-200',
isActive
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_18px_rgba(128,223,231,0.55),0_0_22px_rgba(66,227,255,0.2)]'
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_14px_rgba(128,223,231,0.18)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_18px_rgba(128,223,231,0.3),0_0_18px_rgba(66,227,255,0.12)]',
)}
>
<div
className={cn(
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
isActive
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
)}
/>
<div className="relative flex items-start justify-between gap-design-12">
<SmartImage
src={option.icon}
alt={t(option.labelKey)}
className="h-design-32 w-design-32 shrink-0 rounded-[10px] object-cover shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
/>
{isActive ? (
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-12 py-design-6 text-design-14 font-semibold uppercase tracking-[0.14em] text-[#C9FCFF] shadow-[0_0_14px_rgba(66,227,255,0.18)]">
{t('gameDesktop.control.selected')}
</div>
) : null}
</div>
<div className="relative mt-design-18">
<div className="text-design-24 font-semibold text-[#F3FFFF]">
{t(option.labelKey)}
</div>
<div className="mt-design-8 text-design-15 uppercase tracking-[0.2em] text-[#7EDAE3]">
{option.code}
</div>
</div>
<div className="relative mt-design-16 h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
<div className="relative mt-design-12 flex items-center justify-between text-design-15 text-[#98D6DC]">
<span>{t('language.label')}</span>
<span className="text-[#D8FDFF]">{option.code}</span>
</div>
</button>
)
})}
</div>
</div>
</CenterModal>
)
}
export default DesktopLanguageModal

View File

@@ -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 (
<CenterModal
open={open}
title={
<div className={'modal-title-glow text-design-28'}>
{t('game.modals.protocol.title')}
</div>
}
titleAlign="center"
isShowClose={false}
className={'w-design-980 h-design-680'}
>
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
<div className="flex-1 rounded-[12px] bg-black/35 p-design-18 text-design-18 leading-[1.8] text-[#B9E7EA]">
<div className="h-full overflow-y-auto whitespace-pre-line">
{t('game.modals.protocol.content')}
</div>
</div>
<button
type="button"
onClick={() => setIsChecked((value) => !value)}
className="flex items-center justify-center gap-design-14 self-center text-design-20 text-white"
>
<span className="flex h-design-34 w-design-34 items-center justify-center rounded-[6px] border border-[#80DFE7] bg-slate-950/60">
{isChecked ? (
<SmartImage
src={rightImg}
alt=""
aria-hidden="true"
className="h-design-22 w-design-28 object-contain"
/>
) : null}
</span>
<span>{t('game.modals.protocol.agreeLabel')}</span>
</button>
<div className="flex justify-center">
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 90%"
repeat="no-repeat"
position="center"
onClick={isChecked ? acceptProtocol : undefined}
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold disabled:pointer-events-none disabled:opacity-50"
disabled={!isChecked}
>
{t('game.modals.protocol.confirm')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}
export default DesktopProtocolModal

View File

@@ -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 (
<CenterModal
open={open}
onClose={handleClose}
title={
<div className={'modal-title-glow text-design-28'}>
{t('game.modals.rules.title')}
</div>
}
titleAlign="center"
className={'w-design-1040 h-design-720'}
>
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
<div className="flex-1 overflow-y-auto rounded-[12px] bg-black/35 p-design-20 text-design-18 leading-[1.8] text-[#B9E7EA] whitespace-pre-line">
{t('game.modals.rules.content')}
</div>
<div className="flex justify-center">
<SmartBackground
as="button"
type="button"
src={lengthBlueBtn}
size="100% 90%"
repeat="no-repeat"
position="center"
onClick={handleClose}
className="modal-title-glow flex h-design-72 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
>
{t('game.modals.rules.confirm')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}
export default DesktopRulesModal

View File

@@ -1,4 +1,4 @@
export * from './constants'
export * from './mock-data'
export * from './initial-state'
export * from './selectors'
export * from './types'

View File

@@ -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: [],
}
}

View File

@@ -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
}