import type { ClaimOptionState, MeldState, PendingClaimState, PlayerState, Tile, } from '../../types/state' export function normalizeWsType(type: string): string { return type.replace(/[-\s]/g, '_').toUpperCase() } export function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null } export function readString(source: Record, ...keys: string[]): string { for (const key of keys) { const value = source[key] if (typeof value === 'string' && value.trim()) { return value } } return '' } export function readNumber(source: Record, ...keys: string[]): number | null { for (const key of keys) { const value = source[key] if (typeof value === 'number' && Number.isFinite(value)) { return value } } return null } export function normalizeTimestampMs(value: number | null): number | null { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { return null } return value >= 1_000_000_000_000 ? value : value * 1000 } export function readStringArray(source: Record, ...keys: string[]): string[] { for (const key of keys) { const value = source[key] if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string') } } return [] } export function readBoolean(source: Record, ...keys: string[]): boolean | null { for (const key of keys) { const value = source[key] if (typeof value === 'boolean') { return value } if (typeof value === 'number') { if (value === 1) { return true } if (value === 0) { return false } } if (typeof value === 'string') { const normalized = value.trim().toLowerCase() if (normalized === 'true' || normalized === '1') { return true } if (normalized === 'false' || normalized === '0') { return false } } } return null } export function readMissingSuit(source: Record | null | undefined): string | null { if (!source) { return null } return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null } export function readMissingSuitWithPresence( source: Record | null | undefined, ): { present: boolean; value: string | null } { if (!source) { return { present: false, value: null } } const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit'] const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key)) if (!hasMissingSuitField) { return { present: false, value: null } } return { present: true, value: readMissingSuit(source) } } export function tileToText(tile: Tile): string { return `${tile.suit}${tile.value}` } export function readPlayerTurnPlayerId(payload: Record): string { return ( (typeof payload.player_id === 'string' && payload.player_id) || (typeof payload.playerId === 'string' && payload.playerId) || (typeof payload.PlayerID === 'string' && payload.PlayerID) || '' ) } export function readPlayerTurnAllowActions(payload: Record): string[] { const source = payload.allow_actions ?? payload.allowActions ?? payload.AllowActions ?? payload.available_actions ?? payload.availableActions ?? payload.AvailableActions if (!Array.isArray(source)) { return [] } const actions = source .filter((item): item is string => typeof item === 'string') .map((item) => item.trim().toLowerCase()) .filter((item) => item.length > 0) return Array.from(new Set(actions)) } export function normalizeTile(tile: unknown): Tile | null { const source = asRecord(tile) if (!source) { return null } const id = readNumber(source, 'id') const suit = readString(source, 'suit') as Tile['suit'] | '' const value = readNumber(source, 'value') if (typeof id !== 'number' || !suit || typeof value !== 'number') { return null } if (suit !== 'W' && suit !== 'T' && suit !== 'B') { return null } return { id, suit, value } } export function normalizeTiles(value: unknown): Tile[] { if (!Array.isArray(value)) { return [] } return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item)) } export function normalizePendingClaim( gameState: Record | null | undefined, loggedInUserId: string, ): PendingClaimState | undefined { if (!gameState || !loggedInUserId) { return undefined } const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim) if (!pendingClaim) { return undefined } const selfOptions = asRecord(pendingClaim[loggedInUserId]) if (!selfOptions) { return undefined } const options: ClaimOptionState[] = [] if (readBoolean(selfOptions, 'hu')) { options.push('hu') } if (readBoolean(selfOptions, 'gang')) { options.push('gang') } if (readBoolean(selfOptions, 'peng')) { options.push('peng') } if (options.length === 0) { return undefined } options.push('pass') return { tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined, fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined, options, } } export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null { if (typeof value !== 'string') { return concealed ? 'an_gang' : null } const normalized = value.replace(/[-\s]/g, '_').toLowerCase() if (normalized === 'peng') { return 'peng' } if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') { return concealed ? 'an_gang' : 'ming_gang' } if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') { return 'an_gang' } return concealed ? 'an_gang' : null } export function normalizeMelds(value: unknown): PlayerState['melds'] { if (!Array.isArray(value)) { return [] } return value .map((item) => { if (Array.isArray(item)) { const tiles = normalizeTiles(item) if (tiles.length === 3) { return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState } if (tiles.length === 4) { return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState } return null } const source = asRecord(item) if (!source) { return null } const tiles = normalizeTiles( source.tiles ?? source.meld_tiles ?? source.meldTiles ?? source.cards ?? source.card_list, ) if (tiles.length === 0) { return null } const concealed = readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false const explicitType = normalizeMeldType( source.type ?? source.meld_type ?? source.meldType ?? source.kind, concealed, ) const type = explicitType ?? (tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null) if (type === 'peng' || type === 'ming_gang') { return { type, tiles, fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'), } satisfies MeldState } if (type === 'an_gang') { return { type, tiles } satisfies MeldState } return null }) .filter((item): item is MeldState => Boolean(item)) }