feat: 新增首页和图片资源

This commit is contained in:
JiaJun
2026-04-24 18:02:42 +08:00
parent bd92f10b83
commit 9127a06d4a
179 changed files with 3424 additions and 101 deletions

View File

@@ -0,0 +1,191 @@
import { api } from '@/lib/api/api-client'
import type {
AnnouncementItem,
AnnouncementState,
BetSelection,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundSnapshot,
TrendEntry,
} from '../shared'
import { createMockGameBootstrapSnapshot } from '../shared'
import type {
AnnouncementStateDto,
BetSelectionDto,
ChipDto,
ConnectionStateDto,
DashboardStateDto,
GameAnnouncementsDto,
GameBootstrapDto,
GameCellDto,
GameRoundFeedDto,
HistoryEntryDto,
RoundSnapshotDto,
TrendEntryDto,
} from './types'
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
bootstrap: 'game/bootstrap',
roundFeed: 'game/round-feed',
} as const
function normalizeGameCell(dto: GameCellDto) {
return dto satisfies GameCell
}
function normalizeChip(dto: ChipDto) {
return {
amount: dto.amount,
color: dto.color,
id: dto.id,
isDefault: dto.is_default,
label: dto.label,
}
}
function normalizeBetSelection(dto: BetSelectionDto) {
return {
amount: dto.amount,
cellId: dto.cell_id,
chipId: dto.chip_id,
id: dto.id,
placedAt: dto.placed_at,
source: dto.source,
} satisfies BetSelection
}
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
return {
bettingClosesAt: dto.betting_closes_at,
id: dto.id,
phase: dto.phase,
revealingAt: dto.revealing_at,
settledAt: dto.settled_at,
startedAt: dto.started_at,
winningCellId: dto.winning_cell_id,
} satisfies RoundSnapshot
}
function normalizeHistoryEntry(dto: HistoryEntryDto) {
return {
payoutMultiplier: dto.payout_multiplier,
roundId: dto.round_id,
settledAt: dto.settled_at,
totalPoolAmount: dto.total_pool_amount,
winningCellId: dto.winning_cell_id,
} satisfies HistoryEntry
}
function normalizeTrendEntry(dto: TrendEntryDto) {
return {
cellId: dto.cell_id,
currentStreak: dto.current_streak,
direction: dto.direction,
hitCount: dto.hit_count,
lastHitRoundId: dto.last_hit_round_id,
missCount: dto.miss_count,
} satisfies TrendEntry
}
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
return {
activeAnnouncementId: dto.active_announcement_id,
items: dto.items.map(
(item) =>
({
createdAt: item.created_at,
expiresAt: item.expires_at,
id: item.id,
isPinned: item.is_pinned,
isRead: item.is_read,
message: item.message,
title: item.title,
tone: item.tone,
}) satisfies AnnouncementItem,
),
lastUpdatedAt: dto.last_updated_at,
} satisfies AnnouncementState
}
function normalizeDashboardState(dto: DashboardStateDto) {
return {
countdownMs: dto.countdown_ms,
featuredCellId: dto.featured_cell_id,
onlinePlayers: dto.online_players,
tableLimitMax: dto.table_limit_max,
tableLimitMin: dto.table_limit_min,
totalPoolAmount: dto.total_pool_amount,
updatedAt: dto.updated_at,
} satisfies DashboardState
}
function normalizeConnectionState(dto: ConnectionStateDto) {
return {
connectedAt: dto.connected_at,
lastError: dto.last_error,
lastMessageAt: dto.last_message_at,
latencyMs: dto.latency_ms,
reconnectAttempt: dto.reconnect_attempt,
status: dto.status,
transport: dto.transport,
} satisfies ConnectionState
}
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
return {
announcements: normalizeAnnouncementState(dto.announcements),
cells: dto.cells.map(normalizeGameCell),
chips: dto.chips.map(normalizeChip),
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies GameBootstrapSnapshot
}
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
return {
history: dto.history.map(normalizeHistoryEntry),
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies Pick<
GameBootstrapSnapshot,
'history' | 'round' | 'selections' | 'trends'
>
}
export async function getGameBootstrap() {
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
return normalizeGameBootstrap(response.data)
}
export async function getGameRoundFeed() {
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
return normalizeGameRoundFeed(response.data)
}
export async function getGameAnnouncements() {
const response = await api.get<GameAnnouncementsDto>(
GAME_API_ENDPOINTS.announcements,
)
return normalizeAnnouncementState(response.data.announcements)
}
export async function getMockGameBootstrap(latencyMs = 120) {
await new Promise((resolve) => {
setTimeout(resolve, latencyMs)
})
return createMockGameBootstrapSnapshot()
}

View File

@@ -0,0 +1,2 @@
export * from './game-api'
export * from './types'

View File

@@ -0,0 +1,133 @@
import type {
AnnouncementState,
BetSelection,
Chip,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundSnapshot,
TrendEntry,
} from '../shared'
export interface GameCellDto {
column: number
id: number
label: string
odds: number
row: number
}
export interface ChipDto {
amount: number
color: string
id: string
is_default?: boolean
label: string
}
export interface BetSelectionDto {
amount: number
cell_id: number
chip_id: string
id: string
placed_at: string
source: BetSelection['source']
}
export interface RoundSnapshotDto {
betting_closes_at: string
id: string
phase: RoundSnapshot['phase']
revealing_at: string
settled_at: string | null
started_at: string
winning_cell_id: number | null
}
export interface HistoryEntryDto {
payout_multiplier: number
round_id: string
settled_at: string
total_pool_amount: number
winning_cell_id: number
}
export interface TrendEntryDto {
cell_id: number
current_streak: number
direction: TrendEntry['direction']
hit_count: number
last_hit_round_id: string | null
miss_count: number
}
export interface AnnouncementItemDto {
created_at: string
expires_at: string | null
id: string
is_pinned?: boolean
is_read?: boolean
message: string
title: string
tone: 'info' | 'success' | 'warning' | 'critical'
}
export interface AnnouncementStateDto {
active_announcement_id: string | null
items: AnnouncementItemDto[]
last_updated_at: string | null
}
export interface DashboardStateDto {
countdown_ms: number
featured_cell_id: number | null
online_players: number
table_limit_max: number
table_limit_min: number
total_pool_amount: number
updated_at: string | null
}
export interface ConnectionStateDto {
connected_at: string | null
last_error: string | null
last_message_at: string | null
latency_ms: number | null
reconnect_attempt: number
status: ConnectionState['status']
transport: ConnectionState['transport']
}
export interface GameBootstrapDto {
announcements: AnnouncementStateDto
cells: GameCellDto[]
chips: ChipDto[]
connection: ConnectionStateDto
dashboard: DashboardStateDto
history: HistoryEntryDto[]
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameRoundFeedDto {
history: HistoryEntryDto[]
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameAnnouncementsDto {
announcements: AnnouncementStateDto
}
export type {
AnnouncementState,
Chip,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
}

View File

@@ -0,0 +1,66 @@
import { SmartImage } from '@/components/smart-image'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
import: 'default',
}) as Record<string, string>
const animalImageList = Object.entries(animalModules)
.map(([path, url]) => {
const match = path.match(/\/(\d+)\.webp$/)
return {
id: Number(match?.[1] ?? 0),
url,
}
})
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
interface DesktopAnimalProps {
activeId?: number | null
className?: string
itemClassName?: string
imageClassName?: string
onSelect?: (animalId: number) => void
}
export function DesktopAnimal({
activeId,
className,
itemClassName,
imageClassName,
onSelect,
}: DesktopAnimalProps) {
return (
<section className={cx('grid grid-cols-6', className)}>
{animalImageList.map((item) => {
const isActive = item.id === activeId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
className={cx(
'flex flex-col items-center transition',
'cursor-pointer',
isActive &&
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
itemClassName,
)}
>
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cx('h-[110px] w-[220px]', imageClassName)}
/>
</button>
)
})}
</section>
)
}

View File

@@ -0,0 +1,97 @@
import { CircleAlert, Mail, Plus, Volume2 } from 'lucide-react'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import wifi from '@/assets/system/wifi.webp'
import { SmartImage } from '@/components/smart-image.tsx'
export function DesktopHeader() {
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="h-[70px] w-full flex items-center px-[12px]">
<div className="w-[400px] flex justify-center items-center h-full border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={logo}
alt="logo"
priority
className="w-[320px] h-[40px]"
/>
</div>
<div className="w-[148px] flex justify-center items-center gap-[10px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="w-[28px] h-[20px]"
/>
<div className={'text-[#74FF69] text-[20px]'}>
24 <span className={'text-[16px]'}>ms</span>
</div>
</div>
<div className="w-[175px] flex flex-col justify-center items-center gap-[5px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
<div>System Time</div>
<div>20:05:12 GMT+08</div>
</div>
<div className="flex-1 flex items-center justify-around gap-[10px] h-full px-[20px] text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div className={'flex gap-[10px] common-neon-inset'}>
<CircleAlert color={'#57B8BF'} />
<div>Rules & Ddds</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<Mail color={'#57B8BF'} />
<div>Pesan</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<Volume2 color={'#57B8BF'} />
<div>BGM</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<CircleAlert color={'#57B8BF'} />
<div>ID</div>
</div>
</div>
<div className="flex-1 flex items-center h-full text-[#D5FBFF]">
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
/>
<div
className={
'common-neon-inset !py-[20px] flex items-center justify-center box-border w-[175px] h-[36px]'
}
>
Biomond Balance
</div>
</div>
<div className={'relative flex items-center justify-center h-full'}>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute left-[30px] top-0 z-20 w-[50px] h-[50px]"
/>
<div
className={
'common-neon-inset !py-[20px] flex items-center justify-end gap-[10px] w-[175px] h-[36px]'
}
>
<div>5994469974</div>
<div className={'bg-[#2D4559] rounded-xs cursor-pointer p-[5px]'}>
<Plus size={16} />
</div>
</div>
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,12 @@
import { Megaphone } from 'lucide-react'
export function DesktopTitle() {
return (
<section className="common-neon-inset flex items-center text-[#FF970F] flex gap-[10px] !px-[20px] h-[50px]">
<Megaphone color={'#57B8BF'} />
<div>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
</div>
</section>
)
}

View File

@@ -0,0 +1,2 @@
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'

View File

@@ -0,0 +1,115 @@
import type { ReactNode } from 'react'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
interface GameOverlayAction {
label: string
onClick: () => void
tone?: GameTone
}
interface GameAnnouncementModalProps {
open: boolean
title: string
description?: ReactNode
eyebrow?: string
tone?: GameTone
primaryAction?: GameOverlayAction
secondaryAction?: GameOverlayAction
children?: ReactNode
}
const toneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-slate-950/92',
brand: 'border-cyan-300/25 bg-slate-950/94',
success: 'border-emerald-300/25 bg-slate-950/94',
warning: 'border-amber-300/25 bg-slate-950/94',
danger: 'border-rose-300/25 bg-slate-950/94',
}
const actionToneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
success:
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
warning:
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
}
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
return (
<button
type="button"
onClick={onClick}
className={cx(
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
actionToneClasses[tone],
)}
>
{label}
</button>
)
}
export function GameAnnouncementModal({
open,
title,
description,
eyebrow = '',
tone = 'brand',
primaryAction,
secondaryAction,
children,
}: GameAnnouncementModalProps) {
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
<div
role="dialog"
aria-modal="true"
aria-labelledby="game-announcement-title"
className={cx(
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
toneClasses[tone],
)}
>
<div className="space-y-4">
<div className="space-y-2">
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
{eyebrow}
</span>
<h2
id="game-announcement-title"
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
>
{title}
</h2>
{description ? (
<div className="text-sm leading-7 text-slate-300">
{description}
</div>
) : null}
</div>
{children ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
{children}
</div>
) : null}
{secondaryAction || primaryAction ? (
<div className="flex flex-wrap gap-3 pt-2">
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
{primaryAction ? <ModalAction {...primaryAction} /> : null}
</div>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function GameRoutePage() {
const { t } = useTranslation()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
)
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const markAnnouncementRead = useGameSessionStore(
(state) => state.markAnnouncementRead,
)
const [isHydrating, setIsHydrating] = useState(true)
const activeAnnouncement = useMemo(
() =>
announcements.items.find(
(item) => item.id === announcements.activeAnnouncementId,
) ??
getVisibleAnnouncements(announcements)[0] ??
null,
[announcements],
)
useDocumentMetadata({
title: t('game.metaTitle'),
description: t('game.metaDescription'),
})
useEffect(() => {
let cancelled = false
void getMockGameBootstrap().then((snapshot) => {
if (cancelled) {
return
}
startTransition(() => {
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
})
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
setIsHydrating(false)
})
})
return () => {
cancelled = true
}
}, [hydrateRound, hydrateSession])
return (
<section
aria-busy={isHydrating}
aria-label={t('game.lobbyTitle')}
className="flex min-h-0 flex-1"
>
<GameAnnouncementModal
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
eyebrow={t('game.modal.eyebrow')}
title={activeAnnouncement?.title ?? ''}
description={activeAnnouncement?.message}
primaryAction={{
label: t('game.modal.acknowledge'),
onClick: () => {
if (!activeAnnouncement) {
return
}
markAnnouncementRead(activeAnnouncement.id)
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'brand',
}}
secondaryAction={{
label: t('game.modal.later'),
onClick: () => {
if (!activeAnnouncement) {
return
}
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'neutral',
}}
tone="warning"
>
<div className="space-y-2 text-sm text-slate-300">
<p>{t('game.modal.line1')}</p>
<p>{t('game.modal.line2')}</p>
</div>
</GameAnnouncementModal>
</section>
)
}

View File

@@ -0,0 +1,2 @@
export * from './api'
export * from './shared'

View File

@@ -0,0 +1,61 @@
export const GAME_GRID_ROWS = 6
export const GAME_GRID_COLUMNS = 6
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
export const ROUND_PHASES = [
'waiting',
'betting',
'locked',
'revealing',
'settled',
] as const
export const CELL_STATUSES = [
'idle',
'betting',
'selected',
'locked',
'won',
'lost',
] as const
export const CONNECTION_STATUSES = [
'idle',
'connecting',
'connected',
'reconnecting',
'disconnected',
] as const
export const CONNECTION_TRANSPORTS = [
'websocket',
'polling',
'offline',
] as const
export const ANNOUNCEMENT_TONES = [
'info',
'success',
'warning',
'critical',
] as const
export const BET_SOURCES = ['local', 'server'] as const
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
export const DEFAULT_GAME_CHIP_AMOUNTS = [10, 25, 50, 100, 200, 500] as const
export const DEFAULT_GAME_CHIP_COLORS = [
'#1D4ED8',
'#0F766E',
'#B45309',
'#B91C1C',
'#7C3AED',
'#111827',
] as const
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-50'
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
export const GAME_RECENT_HISTORY_LIMIT = 12
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS

View File

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

View File

@@ -0,0 +1,184 @@
import {
DEFAULT_ACTIVE_CHIP_ID,
DEFAULT_ANNOUNCEMENT_TTL_MS,
DEFAULT_GAME_CHIP_AMOUNTS,
DEFAULT_GAME_CHIP_COLORS,
GAME_GRID_COLUMNS,
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_GAME_CHIP_AMOUNTS.map((amount, index) => ({
amount,
color: DEFAULT_GAME_CHIP_COLORS[index],
id: `chip-${amount}`,
isDefault: `chip-${amount}` === DEFAULT_ACTIVE_CHIP_ID,
label: amount >= 100 ? `${amount / 100}x` : String(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(chips = createDefaultChips()) {
const defaultChip =
chips.find((chip) => chip.id === DEFAULT_ACTIVE_CHIP_ID) ?? chips[0]
return [
{
amount: defaultChip.amount,
cellId: 8,
chipId: defaultChip.id,
id: 'bet-local-1',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 4_000),
source: 'local',
},
{
amount: chips[1]?.amount ?? defaultChip.amount,
cellId: 12,
chipId: chips[1]?.id ?? defaultChip.id,
id: 'bet-server-2',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 7_000),
source: 'server',
},
{
amount: chips[3]?.amount ?? defaultChip.amount,
cellId: 17,
chipId: chips[3]?.id ?? defaultChip.id,
id: 'bet-local-3',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 10_000),
source: 'local',
},
] 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,
round,
selections: createMockBetSelections(chips),
trends: deriveTrendEntries(history),
} satisfies GameBootstrapSnapshot
}

View File

@@ -0,0 +1,184 @@
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
import type {
AnnouncementState,
BetSelection,
Chip,
GameCell,
GameCellViewModel,
HistoryEntry,
RoundSnapshot,
TrendDirection,
TrendEntry,
} from './types'
export function getChipById(chips: Chip[], chipId: string) {
return chips.find((chip) => chip.id === chipId) ?? null
}
export function getSelectionTotal(selections: BetSelection[]) {
return selections.reduce((total, selection) => total + selection.amount, 0)
}
export function groupSelectionsByCell(selections: BetSelection[]) {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
const current = accumulator[selection.cellId] ?? {
amount: 0,
count: 0,
}
accumulator[selection.cellId] = {
amount: current.amount + selection.amount,
count: current.count + 1,
}
return accumulator
},
{},
)
}
export function getRecentWinningCellIds(
history: HistoryEntry[],
limit = GAME_RECENT_HISTORY_LIMIT,
) {
return history.slice(0, limit).map((entry) => entry.winningCellId)
}
export function getUnreadAnnouncementCount(announcements: AnnouncementState) {
return announcements.items.filter((item) => !item.isRead).length
}
export function getVisibleAnnouncements(
announcements: AnnouncementState,
nowIso = new Date().toISOString(),
) {
const now = Date.parse(nowIso)
return announcements.items.filter((item) => {
if (item.expiresAt === null) {
return true
}
return Date.parse(item.expiresAt) > now
})
}
export function getRoundCountdownMs(
round: RoundSnapshot,
nowIso = new Date().toISOString(),
) {
const now = Date.parse(nowIso)
if (round.phase === 'waiting' || round.phase === 'betting') {
return Math.max(0, Date.parse(round.bettingClosesAt) - now)
}
if (round.phase === 'locked' || round.phase === 'revealing') {
return Math.max(0, Date.parse(round.revealingAt) - now)
}
if (round.settledAt) {
return Math.max(0, Date.parse(round.settledAt) - now)
}
return 0
}
export function deriveTrendEntries(
history: HistoryEntry[],
cellCount = GAME_TOTAL_CELLS,
) {
const entries = Array.from({ length: cellCount }, (_, index) => {
const cellId = index + 1
const hitRounds = history.filter((entry) => entry.winningCellId === cellId)
const recentSample = history.slice(0, 6)
const previousSample = history.slice(6, 12)
const recentHits = recentSample.filter(
(entry) => entry.winningCellId === cellId,
).length
const previousHits = previousSample.filter(
(entry) => entry.winningCellId === cellId,
).length
let direction: TrendDirection = 'steady'
if (recentHits > previousHits) {
direction = 'rising'
} else if (recentHits < previousHits) {
direction = 'falling'
}
let currentStreak = 0
for (const entry of history) {
if (entry.winningCellId !== cellId) {
break
}
currentStreak += 1
}
return {
cellId,
currentStreak,
direction,
hitCount: hitRounds.length,
lastHitRoundId: hitRounds[0]?.roundId ?? null,
missCount: history.length - hitRounds.length,
} satisfies TrendEntry
})
return entries.sort((left, right) => {
if (right.hitCount !== left.hitCount) {
return right.hitCount - left.hitCount
}
return left.cellId - right.cellId
})
}
export function buildGameCellViewModels(input: {
cells: GameCell[]
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]
}) {
const groupedSelections = groupSelectionsByCell(input.selections)
const trendByCell = new Map(
input.trends.map((entry) => [entry.cellId, entry]),
)
return input.cells.map((cell) => {
const groupedSelection = groupedSelections[cell.id]
const trend = trendByCell.get(cell.id)
const isSelected = Boolean(groupedSelection)
const isWinningCell = input.round.winningCellId === cell.id
let status: GameCellViewModel['status'] = 'idle'
if (input.round.phase === 'betting') {
status = isSelected ? 'selected' : 'betting'
} else if (
input.round.phase === 'locked' ||
input.round.phase === 'revealing'
) {
status = isSelected ? 'locked' : 'idle'
} else if (input.round.phase === 'settled' && isWinningCell) {
status = 'won'
} else if (input.round.phase === 'settled' && isSelected) {
status = 'lost'
}
return {
...cell,
currentStreak: trend?.currentStreak ?? 0,
hitCount: trend?.hitCount ?? 0,
isSelected,
isWinningCell,
selectionAmount: groupedSelection?.amount ?? 0,
selectionCount: groupedSelection?.count ?? 0,
status,
} satisfies GameCellViewModel
})
}

View File

@@ -0,0 +1,134 @@
import type {
ANNOUNCEMENT_TONES,
BET_SOURCES,
CELL_STATUSES,
CONNECTION_STATUSES,
CONNECTION_TRANSPORTS,
ROUND_PHASES,
TREND_DIRECTIONS,
} from './constants'
export type RoundPhase = (typeof ROUND_PHASES)[number]
export type CellStatus = (typeof CELL_STATUSES)[number]
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
export type BetSource = (typeof BET_SOURCES)[number]
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
export interface GameCell {
column: number
id: number
label: string
odds: number
row: number
}
export interface Chip {
amount: number
color: string
id: string
isDefault?: boolean
label: string
}
export interface BetSelection {
amount: number
cellId: number
chipId: string
id: string
placedAt: string
source: BetSource
}
export interface RoundSnapshot {
bettingClosesAt: string
id: string
phase: RoundPhase
revealingAt: string
settledAt: string | null
startedAt: string
winningCellId: number | null
}
export interface HistoryEntry {
payoutMultiplier: number
roundId: string
settledAt: string
totalPoolAmount: number
winningCellId: number
}
export interface TrendEntry {
cellId: number
currentStreak: number
direction: TrendDirection
hitCount: number
lastHitRoundId: string | null
missCount: number
}
export interface AnnouncementItem {
createdAt: string
expiresAt: string | null
id: string
isPinned?: boolean
isRead?: boolean
message: string
title: string
tone: AnnouncementTone
}
export interface AnnouncementState {
activeAnnouncementId: string | null
items: AnnouncementItem[]
lastUpdatedAt: string | null
}
export interface DashboardState {
countdownMs: number
featuredCellId: number | null
onlinePlayers: number
tableLimitMax: number
tableLimitMin: number
totalPoolAmount: number
updatedAt: string | null
}
export interface ConnectionState {
connectedAt: string | null
lastError: string | null
lastMessageAt: string | null
latencyMs: number | null
reconnectAttempt: number
status: ConnectionStatus
transport: ConnectionTransport
}
export interface GameBootstrapSnapshot {
announcements: AnnouncementState
cells: GameCell[]
chips: Chip[]
connection: ConnectionState
dashboard: DashboardState
history: HistoryEntry[]
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]
}
export interface GameCellViewModel extends GameCell {
currentStreak: number
hitCount: number
isSelected: boolean
isWinningCell: boolean
selectionAmount: number
selectionCount: number
status: CellStatus
}
export interface SelectionSummary {
amount: number
cellId: number
count: number
}