feat(game): 添加游戏大厅音频控制和用户协议功能
- 实现音频资源配置和音频商店状态管理 - 添加用户协议和游戏规则的多语言支持 - 集成音频播放解锁机制和声音开关功能 - 更新API客户端以支持根路径候选 - 优化游戏历史记录组件的滚动加载逻辑 - 添加桌面端控制按钮的动画效果和交互反馈 - 实现语言切换和音效控制的UI组件 - 增加下注相关的状态管理和错误提示 - 完善应用偏好设置的存储和持久化逻辑
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
src/features/game/audio/audio-config.ts
Normal file
19
src/features/game/audio/audio-config.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
103
src/features/game/audio/global-audio-controller.tsx
Normal file
103
src/features/game/audio/global-audio-controller.tsx
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
|
||||
55
src/features/game/hooks/use-app-language.ts
Normal file
55
src/features/game/hooks/use-app-language.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
39
src/features/game/hooks/use-protocol-agreement.ts
Normal file
39
src/features/game/hooks/use-protocol-agreement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
100
src/features/game/modal/desktop/desktop-language-modal.tsx
Normal file
100
src/features/game/modal/desktop/desktop-language-modal.tsx
Normal 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
|
||||
76
src/features/game/modal/desktop/desktop-protocol-modal.tsx
Normal file
76
src/features/game/modal/desktop/desktop-protocol-modal.tsx
Normal 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
|
||||
52
src/features/game/modal/desktop/desktop-rules-modal.tsx
Normal file
52
src/features/game/modal/desktop/desktop-rules-modal.tsx
Normal 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
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './constants'
|
||||
export * from './mock-data'
|
||||
export * from './initial-state'
|
||||
export * from './selectors'
|
||||
export * from './types'
|
||||
|
||||
81
src/features/game/shared/initial-state.ts
Normal file
81
src/features/game/shared/initial-state.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user