+7
-1
| { | ||
| "name": "wumingmud", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "description": "无名江湖 - TUI客户端", | ||
| "main": "dist/index.js", | ||
| "bin": { | ||
| "wumingmud": "dist/index.js" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "type": "module", | ||
@@ -7,0 +13,0 @@ "homepage": "https://github.com/ivershuo/wumingmud-tui", |
+12
-6
@@ -9,4 +9,10 @@ # 无名江湖TUI客户端(`wumingmud-tui`) | ||
| ## 技术栈 | ||
| ## 进入江湖 | ||
| ```bash | ||
| npx wumingmud | ||
| ``` | ||
| ## FOR DEVELOPERS | ||
| ### 技术栈 | ||
| - Node.js 18+ | ||
@@ -19,3 +25,3 @@ - TypeScript | ||
| ## 目录结构 | ||
| ### 目录结构 | ||
@@ -37,3 +43,3 @@ ```text | ||
| ## 环境要求 | ||
| ### 环境要求 | ||
@@ -43,3 +49,3 @@ - Node.js 18+ | ||
| ## 快速开始 | ||
| ### 快速开始 | ||
@@ -54,3 +60,3 @@ ```bash | ||
| ## 环境变量 | ||
| ### 环境变量 | ||
@@ -66,3 +72,3 @@ | 变量名 | 默认值 | 说明 | | ||
| ## 常用命令 | ||
| ### 常用命令 | ||
@@ -69,0 +75,0 @@ ```bash |
Sorry, the diff of this file is not supported yet
| name: Publish npm package | ||
| on: | ||
| push: | ||
| tags: | ||
| - 'v*' | ||
| jobs: | ||
| publish: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 20 | ||
| cache: npm | ||
| registry-url: https://registry.npmjs.org | ||
| - name: Validate tag version | ||
| run: | | ||
| TAG_VERSION="${GITHUB_REF_NAME#v}" | ||
| PKG_VERSION=$(node -p "require('./package.json').version") | ||
| if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then | ||
| echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" | ||
| exit 1 | ||
| fi | ||
| - name: Install dependencies | ||
| run: npm ci | ||
| - name: Build | ||
| run: npm run build | ||
| - name: Publish to npm | ||
| run: npm publish --provenance --access public | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
| import * as esbuild from 'esbuild' | ||
| await esbuild.build({ | ||
| entryPoints: ['src/index.tsx'], | ||
| bundle: true, | ||
| platform: 'node', | ||
| target: 'node18', | ||
| outfile: 'dist/index.js', | ||
| format: 'esm', | ||
| banner: { | ||
| js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', | ||
| }, | ||
| minify: true, | ||
| define: { | ||
| 'process.env.NODE_ENV': '"production"', | ||
| '__DEV__': 'false', | ||
| } | ||
| }) | ||
| console.log('Build completed!') |
| import React, { useEffect, useRef } from 'react' | ||
| import { Box, useStdout } from 'ink' | ||
| import { useStore } from '../store' | ||
| import { useWebSocket } from '../hooks/useWebSocket' | ||
| import { useReconnect } from '../hooks/useReconnect' | ||
| import { LoginPanel } from './LoginPanel' | ||
| import { Layout } from './Layout' | ||
| import { Notification } from './Notification' | ||
| import { logError } from '../services/logger' | ||
| export const App = () => { | ||
| const { stdout } = useStdout() | ||
| const { isAuthenticated, connectionStatus } = useStore() | ||
| const { connect, disconnect } = useWebSocket() | ||
| const { startReconnect, stopReconnect } = useReconnect() | ||
| const hasConnectedRef = useRef(false) | ||
| useEffect(() => { | ||
| if (isAuthenticated) { | ||
| void connect().catch((err) => { | ||
| logError('app.ws.connect_failed', err, { | ||
| phase: 'ws_connect', | ||
| }) | ||
| }) | ||
| } | ||
| return () => { | ||
| disconnect() | ||
| stopReconnect() | ||
| } | ||
| }, [isAuthenticated, connect, disconnect, stopReconnect]) | ||
| useEffect(() => { | ||
| if (connectionStatus === 'connected') { | ||
| hasConnectedRef.current = true | ||
| } | ||
| }, [connectionStatus]) | ||
| useEffect(() => { | ||
| if (connectionStatus === 'disconnected' && isAuthenticated) { | ||
| if (!hasConnectedRef.current) return | ||
| startReconnect() | ||
| } | ||
| }, [connectionStatus, isAuthenticated, startReconnect]) | ||
| useEffect(() => { | ||
| stdout.write('\x1b[?25l') | ||
| return () => { | ||
| stdout.write('\x1b[?25h') | ||
| } | ||
| }, [stdout]) | ||
| if (!isAuthenticated) { | ||
| return <LoginPanel /> | ||
| } | ||
| return ( | ||
| <Box flexDirection="column" height={stdout.rows}> | ||
| <Layout /> | ||
| <Notification /> | ||
| </Box> | ||
| ) | ||
| } |
| import { Box, Text } from 'ink' | ||
| import { useStore } from '../store' | ||
| import { formatTime } from '../utils/narrative' | ||
| import type { ChatChannel } from '../types/state' | ||
| export const ChatPanel = () => { | ||
| const { chatMessages, activeChatTab } = useStore() | ||
| const tabs: ChatChannel[] = ['room', 'guild', 'private'] | ||
| const filteredMessages = chatMessages.filter(msg => { | ||
| if (activeChatTab === 'room') return msg.type === 'room' | ||
| if (activeChatTab === 'guild') return msg.type === 'guild' || msg.type === 'system' | ||
| if (activeChatTab === 'private') return msg.type === 'private' | ||
| return true | ||
| }) | ||
| const getTypeColor = (type: ChatChannel): string => { | ||
| switch (type) { | ||
| case 'room': return 'green' | ||
| case 'guild': return 'magenta' | ||
| case 'private': return 'yellow' | ||
| case 'system': return 'cyan' | ||
| default: return 'white' | ||
| } | ||
| } | ||
| const getTypeLabel = (type: ChatChannel): string => { | ||
| switch (type) { | ||
| case 'room': return '附近' | ||
| case 'guild': return '帮派' | ||
| case 'private': return '私聊' | ||
| case 'system': return '系统' | ||
| default: return '' | ||
| } | ||
| } | ||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| borderStyle="single" | ||
| borderColor="green" | ||
| padding={1} | ||
| > | ||
| <Box marginBottom={1}> | ||
| {tabs.map((tab) => ( | ||
| <Box key={tab} marginRight={2}> | ||
| <Text | ||
| color={activeChatTab === tab ? 'white' : 'gray'} | ||
| backgroundColor={activeChatTab === tab ? 'blue' : undefined} | ||
| bold={activeChatTab === tab} | ||
| > | ||
| [{getTypeLabel(tab)}] | ||
| </Text> | ||
| </Box> | ||
| ))} | ||
| <Text color="gray"> (Tab/Shift+Tab 切换)</Text> | ||
| </Box> | ||
| <Box flexDirection="column"> | ||
| {filteredMessages.length === 0 ? ( | ||
| <Text color="gray">暂无消息...</Text> | ||
| ) : ( | ||
| filteredMessages.slice(-15).map((msg) => ( | ||
| <Box key={msg.id} marginBottom={1}> | ||
| <Text color="gray"> | ||
| {formatTime(msg.timestamp)} | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text color={getTypeColor(msg.type)}> | ||
| [{getTypeLabel(msg.type)}] | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text color="cyan">{msg.sender.name}:</Text> | ||
| <Text> </Text> | ||
| <Text>{msg.content}</Text> | ||
| </Box> | ||
| )) | ||
| )} | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } |
| import { useState } from 'react' | ||
| import { Box, Text, useInput, useApp } from 'ink' | ||
| import TextInput from 'ink-text-input' | ||
| import { useStore } from '../store' | ||
| import { parseCommand } from '../services/parser' | ||
| import { logout } from '../services/auth' | ||
| import { wsService } from '../services/websocket' | ||
| export const InputPanel = () => { | ||
| const [input, setInput] = useState('') | ||
| const [history, setHistory] = useState<string[]>([]) | ||
| const [historyIndex, setHistoryIndex] = useState(-1) | ||
| const { exit } = useApp() | ||
| const { connectionStatus, setAuthenticated, setPlayer, cycleChatTab } = useStore() | ||
| useInput((_, key) => { | ||
| if (key.tab) { | ||
| cycleChatTab(!!key.shift) | ||
| return | ||
| } | ||
| if (key.upArrow && history.length > 0) { | ||
| const newIndex = historyIndex < 0 | ||
| ? history.length - 1 | ||
| : Math.max(0, historyIndex - 1) | ||
| setHistoryIndex(newIndex) | ||
| setInput(history[newIndex]) | ||
| } | ||
| if (key.downArrow && historyIndex >= 0) { | ||
| const newIndex = historyIndex + 1 | ||
| if (newIndex >= history.length) { | ||
| setHistoryIndex(-1) | ||
| setInput('') | ||
| } else { | ||
| setHistoryIndex(newIndex) | ||
| setInput(history[newIndex]) | ||
| } | ||
| } | ||
| if (key.ctrl && input === 'c') { | ||
| exit() | ||
| } | ||
| }) | ||
| const handleSubmit = () => { | ||
| if (!input.trim()) return | ||
| const trimmed = input.trim() | ||
| if (trimmed.startsWith('/')) { | ||
| const command = trimmed.slice(1).trim().toLowerCase() | ||
| if (command === 'q' || command === 'exit') { | ||
| exit() | ||
| return | ||
| } | ||
| if (command === 'logout') { | ||
| wsService.disconnect() | ||
| logout() | ||
| setPlayer(null) | ||
| setAuthenticated(false) | ||
| setInput('') | ||
| useStore.getState().addNotification({ | ||
| id: Date.now().toString(), | ||
| type: 'info', | ||
| message: '已退出登录', | ||
| }) | ||
| return | ||
| } | ||
| } | ||
| const command = parseCommand(trimmed) | ||
| if (connectionStatus !== 'connected') { | ||
| useStore.getState().addNotification({ | ||
| id: Date.now().toString(), | ||
| type: 'warning', | ||
| message: '未连接到服务器', | ||
| }) | ||
| setInput('') | ||
| return | ||
| } | ||
| if (command.type !== 'empty') { | ||
| wsService.send({ | ||
| type: command.type, | ||
| timestamp: Date.now(), | ||
| data: command.data | ||
| }) | ||
| } | ||
| setHistory([...history, trimmed]) | ||
| setHistoryIndex(-1) | ||
| setInput('') | ||
| } | ||
| return ( | ||
| <Box | ||
| borderStyle="single" | ||
| borderColor="yellow" | ||
| padding={1} | ||
| > | ||
| <Text color="yellow">{'>'}</Text> | ||
| <Text> </Text> | ||
| <TextInput | ||
| value={input} | ||
| onChange={setInput} | ||
| onSubmit={handleSubmit} | ||
| placeholder="输入指令..." | ||
| /> | ||
| </Box> | ||
| ) | ||
| } |
| import { Box } from 'ink' | ||
| import { StatusBar } from './StatusBar' | ||
| import { WorldPanel } from './WorldPanel' | ||
| import { RoomPanel } from './RoomPanel' | ||
| import { ChatPanel } from './ChatPanel' | ||
| import { InputPanel } from './InputPanel' | ||
| export const Layout = () => { | ||
| return ( | ||
| <Box flexDirection="column" flexGrow={1}> | ||
| <StatusBar /> | ||
| <Box flexDirection="column" flexGrow={1}> | ||
| <Box flexDirection="column" flexGrow={1} height="30%"> | ||
| <WorldPanel /> | ||
| </Box> | ||
| <Box flexDirection="column" flexGrow={1} height="20%"> | ||
| <RoomPanel /> | ||
| </Box> | ||
| <Box flexDirection="column" flexGrow={1}> | ||
| <ChatPanel /> | ||
| </Box> | ||
| </Box> | ||
| <InputPanel /> | ||
| </Box> | ||
| ) | ||
| } |
| import { useState } from 'react' | ||
| import { Box, Text, useInput } from 'ink' | ||
| import TextInput from 'ink-text-input' | ||
| import { useStore } from '../store' | ||
| import { AuthRequestError, login, register } from '../services/auth' | ||
| import { beginTrace, logError, shortTraceId } from '../services/logger' | ||
| type LoginMode = 'login' | 'register' | ||
| type LoginField = 'username' | 'password' | 'confirmPassword' | 'name' | ||
| export const LoginPanel = () => { | ||
| const [mode, setMode] = useState<LoginMode>('login') | ||
| const [activeField, setActiveField] = useState<LoginField>('username') | ||
| const [username, setUsername] = useState('') | ||
| const [password, setPassword] = useState('') | ||
| const [confirmPassword, setConfirmPassword] = useState('') | ||
| const [name, setName] = useState('') | ||
| const [error, setError] = useState('') | ||
| const [loading, setLoading] = useState(false) | ||
| const [traceHint, setTraceHint] = useState('') | ||
| const { setAuthenticated, setPlayer } = useStore() | ||
| const fieldsByMode: Record<LoginMode, LoginField[]> = { | ||
| login: ['username', 'password'], | ||
| register: ['username', 'password', 'confirmPassword', 'name'], | ||
| } | ||
| const cycleField = (reverse = false) => { | ||
| const fields = fieldsByMode[mode] | ||
| const currentIndex = fields.indexOf(activeField) | ||
| const index = currentIndex < 0 ? 0 : currentIndex | ||
| const nextIndex = reverse | ||
| ? (index - 1 + fields.length) % fields.length | ||
| : (index + 1) % fields.length | ||
| setActiveField(fields[nextIndex]) | ||
| } | ||
| const toggleMode = () => { | ||
| setMode(mode === 'login' ? 'register' : 'login') | ||
| setActiveField('username') | ||
| setError('') | ||
| setTraceHint('') | ||
| } | ||
| useInput((_, key) => { | ||
| if (key.tab) { | ||
| cycleField(!!key.shift) | ||
| } | ||
| if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { | ||
| toggleMode() | ||
| } | ||
| }) | ||
| const handleSubmit = async () => { | ||
| setError('') | ||
| setTraceHint('') | ||
| setLoading(true) | ||
| const traceId = beginTrace() | ||
| try { | ||
| if (mode === 'register') { | ||
| if (!name.trim()) { | ||
| setError('请输入角色名') | ||
| return | ||
| } | ||
| if (password !== confirmPassword) { | ||
| setError('两次输入的密码不一致') | ||
| return | ||
| } | ||
| } | ||
| if (!username.trim() || !password.trim()) { | ||
| setError('请输入用户名和密码') | ||
| return | ||
| } | ||
| const result = mode === 'login' | ||
| ? await login({ username, password }) | ||
| : await register({ username, password, name }) | ||
| if (result.success && result.data) { | ||
| setPlayer(result.data.player as any) | ||
| setAuthenticated(true) | ||
| } else { | ||
| setError(result.error || result.message || '操作失败') | ||
| } | ||
| } catch (err) { | ||
| if (err instanceof AuthRequestError) { | ||
| setTraceHint(shortTraceId(err.traceId)) | ||
| switch (err.kind) { | ||
| case 'network': | ||
| setError('网络连接失败,请确认服务已启动') | ||
| break | ||
| case 'http': | ||
| setError(`服务错误(${err.statusCode || '-'}),请稍后重试`) | ||
| break | ||
| case 'parse': | ||
| setError('服务响应异常,请查看服务端日志') | ||
| break | ||
| case 'auth': | ||
| setError(err.message || '认证失败,请检查账号密码') | ||
| break | ||
| default: | ||
| setError(err.message || '请求失败') | ||
| } | ||
| } else { | ||
| setError('未知错误,请稍后重试') | ||
| } | ||
| logError('auth.ui.submit_failed', err, { | ||
| trace_id: traceId, | ||
| phase: 'auth_http', | ||
| mode, | ||
| }) | ||
| } finally { | ||
| setLoading(false) | ||
| } | ||
| } | ||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| alignItems="center" | ||
| justifyContent="center" | ||
| height="100%" | ||
| > | ||
| <Box marginBottom={2}> | ||
| <Text bold color="yellow">欢迎来到 无名江湖</Text> | ||
| </Box> | ||
| <Box marginBottom={1}> | ||
| <Text | ||
| color={mode === 'login' ? 'white' : 'gray'} | ||
| backgroundColor={mode === 'login' ? 'blue' : undefined} | ||
| > | ||
| {' [登录] '} | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text | ||
| color={mode === 'register' ? 'white' : 'gray'} | ||
| backgroundColor={mode === 'register' ? 'blue' : undefined} | ||
| > | ||
| {' [注册] '} | ||
| </Text> | ||
| <Text color="gray"> (方向键切换模式)</Text> | ||
| </Box> | ||
| <Box flexDirection="column" width={40}> | ||
| <Box marginBottom={1}> | ||
| <Text color="gray">用户名: </Text> | ||
| <TextInput | ||
| value={username} | ||
| onChange={setUsername} | ||
| focus={activeField === 'username'} | ||
| onSubmit={() => setActiveField('password')} | ||
| /> | ||
| </Box> | ||
| <Box marginBottom={1}> | ||
| <Text color="gray">密码: </Text> | ||
| <TextInput | ||
| value={password} | ||
| onChange={setPassword} | ||
| mask="*" | ||
| focus={activeField === 'password'} | ||
| onSubmit={() => { | ||
| if (mode === 'login') { | ||
| void handleSubmit() | ||
| } else { | ||
| setActiveField('confirmPassword') | ||
| } | ||
| }} | ||
| /> | ||
| </Box> | ||
| {mode === 'register' && ( | ||
| <> | ||
| <Box marginBottom={1}> | ||
| <Text color="gray">确认密码:</Text> | ||
| <TextInput | ||
| value={confirmPassword} | ||
| onChange={setConfirmPassword} | ||
| mask="*" | ||
| focus={activeField === 'confirmPassword'} | ||
| onSubmit={() => { | ||
| setActiveField('name') | ||
| }} | ||
| /> | ||
| </Box> | ||
| <Box marginBottom={1}> | ||
| <Text color="gray">角色名: </Text> | ||
| <TextInput | ||
| value={name} | ||
| onChange={setName} | ||
| focus={activeField === 'name'} | ||
| onSubmit={() => void handleSubmit()} | ||
| /> | ||
| </Box> | ||
| </> | ||
| )} | ||
| {error && ( | ||
| <Box marginBottom={1}> | ||
| <Text color="red"> | ||
| {error} | ||
| {traceHint ? ` (trace:${traceHint})` : ''} | ||
| </Text> | ||
| </Box> | ||
| )} | ||
| {loading && ( | ||
| <Box marginBottom={1}> | ||
| <Text color="yellow">处理中...</Text> | ||
| </Box> | ||
| )} | ||
| <Box marginTop={1}> | ||
| <Text color="gray">Enter 确认 | Tab 切输入框 | 方向键切换登录/注册 | Ctrl+C 退出</Text> | ||
| </Box> | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } |
| import { useEffect } from 'react' | ||
| import { Box, Text } from 'ink' | ||
| import { useStore } from '../store' | ||
| export const Notification = () => { | ||
| const { notifications } = useStore() | ||
| useEffect(() => { | ||
| notifications.forEach(notification => { | ||
| if (notification.duration) { | ||
| setTimeout(() => { | ||
| useStore.getState().removeNotification(notification.id) | ||
| }, notification.duration) | ||
| } | ||
| }) | ||
| }, [notifications]) | ||
| if (notifications.length === 0) { | ||
| return null | ||
| } | ||
| const getNotificationColor = (type: string): string => { | ||
| switch (type) { | ||
| case 'success': return 'green' | ||
| case 'error': return 'red' | ||
| case 'warning': return 'yellow' | ||
| case 'info': return 'cyan' | ||
| default: return 'white' | ||
| } | ||
| } | ||
| return ( | ||
| <Box | ||
| position="absolute" | ||
| top={0} | ||
| left={0} | ||
| padding={1} | ||
| flexDirection="column" | ||
| > | ||
| {notifications.map((notification) => ( | ||
| <Box key={notification.id} marginBottom={1}> | ||
| <Box | ||
| borderStyle="single" | ||
| borderColor={getNotificationColor(notification.type)} | ||
| padding={1} | ||
| > | ||
| <Text color={getNotificationColor(notification.type)}> | ||
| {notification.message} | ||
| </Text> | ||
| </Box> | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| ) | ||
| } |
| import { Box, Text } from 'ink' | ||
| import { useStore } from '../store' | ||
| export const RoomPanel = () => { | ||
| const { currentRoom, onlinePlayers, npcsInRoom } = useStore() | ||
| if (!currentRoom) { | ||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| borderStyle="single" | ||
| borderColor="blue" | ||
| padding={1} | ||
| > | ||
| <Text color="gray">暂无位置信息...</Text> | ||
| </Box> | ||
| ) | ||
| } | ||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| borderStyle="single" | ||
| borderColor="blue" | ||
| padding={1} | ||
| > | ||
| <Box marginBottom={1}> | ||
| <Text bold color="yellow">【{currentRoom.name}】</Text> | ||
| </Box> | ||
| <Box marginBottom={1}> | ||
| <Text color="gray">{currentRoom.description}</Text> | ||
| </Box> | ||
| {npcsInRoom.length > 0 && ( | ||
| <Box marginBottom={1}> | ||
| <Text color="green">这里的人:{' '}</Text> | ||
| {npcsInRoom.map((npc, index) => ( | ||
| <Text key={npc.id} color="green"> | ||
| {npc.name} | ||
| {index < npcsInRoom.length - 1 ? '、' : ''} | ||
| </Text> | ||
| ))} | ||
| </Box> | ||
| )} | ||
| {onlinePlayers.length > 1 && ( | ||
| <Box marginBottom={1}> | ||
| <Text color="cyan">在线玩家:{' '}</Text> | ||
| {onlinePlayers.filter(p => p.id !== useStore.getState().player?.id).map((player, index) => ( | ||
| <Text key={player.id} color="cyan"> | ||
| {player.name} | ||
| {index < onlinePlayers.length - 1 ? '、' : ''} | ||
| </Text> | ||
| ))} | ||
| </Box> | ||
| )} | ||
| {currentRoom.exits.length > 0 && ( | ||
| <Box> | ||
| <Text color="yellow">出口:{' '}</Text> | ||
| {currentRoom.exits.map((exit, index) => ( | ||
| <Text key={exit.direction} color="yellow"> | ||
| {exit.name} | ||
| {index < currentRoom.exits.length - 1 ? ' ' : ''} | ||
| </Text> | ||
| ))} | ||
| </Box> | ||
| )} | ||
| </Box> | ||
| ) | ||
| } |
| import { Box, Text } from 'ink' | ||
| import { useStore } from '../store' | ||
| export const StatusBar = () => { | ||
| const { player, connectionStatus, onlineCount } = useStore() | ||
| const getStatusColor = () => { | ||
| switch (connectionStatus) { | ||
| case 'connected': return 'green' | ||
| case 'connecting': return 'yellow' | ||
| case 'disconnected': return 'red' | ||
| case 'error': return 'red' | ||
| default: return 'gray' | ||
| } | ||
| } | ||
| const getStatusText = () => { | ||
| switch (connectionStatus) { | ||
| case 'connected': return '已连接' | ||
| case 'connecting': return '连接中...' | ||
| case 'disconnected': return '未连接' | ||
| case 'error': return '错误' | ||
| default: return connectionStatus | ||
| } | ||
| } | ||
| const formatHP = () => { | ||
| if (!player) return '--/--' | ||
| const hp = player.hp ?? 0 | ||
| const maxHP = player.max_hp ?? 0 | ||
| return `${hp}/${maxHP}` | ||
| } | ||
| const formatMP = () => { | ||
| if (!player) return '--/--' | ||
| const mp = player.mp ?? 0 | ||
| const maxMP = player.max_mp ?? 0 | ||
| return `${mp}/${maxMP}` | ||
| } | ||
| return ( | ||
| <Box | ||
| flexDirection="row" | ||
| borderStyle="single" | ||
| borderColor="gray" | ||
| paddingX={1} | ||
| justifyContent="space-between" | ||
| > | ||
| <Box marginRight={2}> | ||
| <Text bold color="cyan"> | ||
| {player?.name || '未登录'} | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text color="yellow"> | ||
| Lv.{player?.level || 0} | ||
| </Text> | ||
| </Box> | ||
| <Box marginRight={2}> | ||
| <Text color="red">HP: {formatHP()}</Text> | ||
| <Text> </Text> | ||
| <Text color="blue">MP: {formatMP()}</Text> | ||
| </Box> | ||
| <Box marginRight={2}> | ||
| <Text color="green">在线: {onlineCount ?? 0}</Text> | ||
| </Box> | ||
| <Box> | ||
| <Text color={getStatusColor()}>{getStatusText()}</Text> | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } |
| import { Box, Text } from 'ink' | ||
| import { useStore } from '../store' | ||
| import { formatTime } from '../utils/narrative' | ||
| export const WorldPanel = () => { | ||
| const { worldEvents } = useStore() | ||
| const getEventColor = (type: string): string => { | ||
| switch (type) { | ||
| case 'system': return 'cyan' | ||
| case 'world': return 'yellow' | ||
| case 'combat': return 'red' | ||
| case 'narrative': return 'green' | ||
| default: return 'white' | ||
| } | ||
| } | ||
| return ( | ||
| <Box | ||
| flexDirection="column" | ||
| borderStyle="single" | ||
| borderColor="gray" | ||
| padding={1} | ||
| > | ||
| <Box marginBottom={1}> | ||
| <Text bold underline color="white">江湖大事</Text> | ||
| </Box> | ||
| <Box flexDirection="column"> | ||
| {worldEvents.length === 0 ? ( | ||
| <Text color="gray">暂无消息...</Text> | ||
| ) : ( | ||
| worldEvents.slice(-10).map((event) => ( | ||
| <Box key={event.id} marginBottom={1}> | ||
| <Text color="gray"> | ||
| {formatTime(event.timestamp)} | ||
| </Text> | ||
| <Text> </Text> | ||
| <Text color={getEventColor(event.type)}> | ||
| {event.content} | ||
| </Text> | ||
| </Box> | ||
| )) | ||
| )} | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } |
| import { useCallback, useRef } from 'react' | ||
| import { wsService } from '../services/websocket' | ||
| import { useStore } from '../store' | ||
| import { getReconnectNarrative } from '../utils/narrative' | ||
| import { logError } from '../services/logger' | ||
| const MAX_RETRIES = 5 | ||
| const INITIAL_DELAY = 1000 | ||
| const MAX_DELAY = 30000 | ||
| export function useReconnect() { | ||
| const retryCountRef = useRef(0) | ||
| const timeoutIdRef = useRef<NodeJS.Timeout | undefined>(undefined) | ||
| const startReconnect = useCallback(() => { | ||
| if (retryCountRef.current >= MAX_RETRIES) { | ||
| useStore.getState().addWorldEvent({ | ||
| id: Date.now().toString(), | ||
| type: 'system', | ||
| content: '多次重连失败,请检查网络后刷新重试', | ||
| timestamp: Date.now(), | ||
| }) | ||
| return | ||
| } | ||
| const delay = Math.min( | ||
| INITIAL_DELAY * Math.pow(2, retryCountRef.current), | ||
| MAX_DELAY | ||
| ) | ||
| useStore.getState().addWorldEvent({ | ||
| id: Date.now().toString(), | ||
| type: 'narrative', | ||
| content: getReconnectNarrative(retryCountRef.current), | ||
| timestamp: Date.now(), | ||
| }) | ||
| timeoutIdRef.current = setTimeout(() => { | ||
| retryCountRef.current++ | ||
| void wsService.connect().catch((err) => { | ||
| logError('ws.reconnect.failed', err, { | ||
| phase: 'ws_connect', | ||
| reconnect_attempt: retryCountRef.current, | ||
| }) | ||
| }) | ||
| }, delay) | ||
| }, []) | ||
| const stopReconnect = useCallback(() => { | ||
| if (timeoutIdRef.current) { | ||
| clearTimeout(timeoutIdRef.current) | ||
| timeoutIdRef.current = undefined | ||
| } | ||
| retryCountRef.current = 0 | ||
| }, []) | ||
| return { | ||
| startReconnect, | ||
| stopReconnect, | ||
| } | ||
| } |
| import { useEffect, useCallback } from 'react' | ||
| import { wsService } from '../services/websocket' | ||
| import { useStore } from '../store' | ||
| import type { ServerMessage } from '../types/message' | ||
| import { logInfo } from '../services/logger' | ||
| export function useWebSocket() { | ||
| const { | ||
| setConnectionStatus, | ||
| setOnlineCount, | ||
| updatePlayer, | ||
| updateRoom, | ||
| addWorldEvent, | ||
| addChatMessage, | ||
| updateCombat, | ||
| clearCombat, | ||
| } = useStore() | ||
| const handleMessage = useCallback((message: ServerMessage) => { | ||
| switch (message.type) { | ||
| case 'auth_ok': | ||
| if (message.data.player) { | ||
| updatePlayer(message.data.player) | ||
| } | ||
| if (message.data.room) { | ||
| updateRoom(message.data.room) | ||
| } | ||
| break | ||
| case 'auth_failed': | ||
| useStore.getState().addNotification({ | ||
| id: Date.now().toString(), | ||
| type: 'error', | ||
| message: message.data.message || '认证失败', | ||
| }) | ||
| break | ||
| case 'room_update': | ||
| updateRoom(message.data) | ||
| break | ||
| case 'player_update': | ||
| updatePlayer(message.data) | ||
| break | ||
| case 'world_event': | ||
| addWorldEvent({ | ||
| id: message.data.id || Date.now().toString(), | ||
| type: message.data.type || 'system', | ||
| content: message.data.content, | ||
| timestamp: message.timestamp, | ||
| }) | ||
| break | ||
| case 'online_update': | ||
| setOnlineCount(Number(message.data.count) || 0) | ||
| break | ||
| case 'chat': | ||
| addChatMessage({ | ||
| id: message.data.id || Date.now().toString(), | ||
| type: message.data.channel, | ||
| sender: message.data.sender, | ||
| content: message.data.content, | ||
| timestamp: message.timestamp, | ||
| }) | ||
| break | ||
| case 'combat_start': | ||
| updateCombat(message.data) | ||
| break | ||
| case 'combat_round': | ||
| useStore.getState().updateCombat(message.data) | ||
| addWorldEvent({ | ||
| id: Date.now().toString(), | ||
| type: 'combat', | ||
| content: message.data.narrative || '战斗进行中...', | ||
| timestamp: Date.now(), | ||
| }) | ||
| break | ||
| case 'combat_end': | ||
| clearCombat() | ||
| addWorldEvent({ | ||
| id: Date.now().toString(), | ||
| type: 'combat', | ||
| content: message.data.narrative || '战斗结束', | ||
| timestamp: Date.now(), | ||
| }) | ||
| break | ||
| case 'error': | ||
| const errorMessage = message.data.narrative || message.data.message || '操作失败' | ||
| addWorldEvent({ | ||
| id: Date.now().toString(), | ||
| type: 'system', | ||
| content: errorMessage, | ||
| timestamp: Date.now(), | ||
| }) | ||
| useStore.getState().addNotification({ | ||
| id: Date.now().toString(), | ||
| type: 'error', | ||
| message: errorMessage, | ||
| }) | ||
| break | ||
| case 'quest_update': | ||
| addWorldEvent({ | ||
| id: message.data.id || Date.now().toString(), | ||
| type: 'narrative', | ||
| content: message.data.narrative || message.data.message || '任务状态更新', | ||
| timestamp: Date.now(), | ||
| }) | ||
| break | ||
| case 'pong': | ||
| break | ||
| default: | ||
| logInfo('ws.message.unhandled', { | ||
| trace_id: message.trace_id, | ||
| request_id: message.request_id, | ||
| phase: 'ws_message', | ||
| message_type: message.type, | ||
| }) | ||
| } | ||
| }, [setOnlineCount, updatePlayer, updateRoom, addWorldEvent, addChatMessage, updateCombat, clearCombat]) | ||
| useEffect(() => { | ||
| const unsubscribe = wsService.onMessage(handleMessage) | ||
| return unsubscribe | ||
| }, [handleMessage]) | ||
| useEffect(() => { | ||
| const unsubscribe = wsService.onStatusChange((status) => { | ||
| setConnectionStatus(status) | ||
| }) | ||
| return unsubscribe | ||
| }, [setConnectionStatus]) | ||
| const connect = useCallback(() => { | ||
| return wsService.connect() | ||
| }, []) | ||
| const disconnect = useCallback(() => { | ||
| wsService.disconnect() | ||
| }, []) | ||
| const send = useCallback((message: any) => { | ||
| return wsService.send(message) | ||
| }, []) | ||
| return { | ||
| connect, | ||
| disconnect, | ||
| send, | ||
| connectionStatus: wsService.getConnectionStatus(), | ||
| } | ||
| } |
| import React from 'react' | ||
| import { render } from 'ink' | ||
| import { App } from './components/App' | ||
| render(<App />) |
| import type { LoginRequest, RegisterRequest, AuthResponse } from '../types/api' | ||
| import { ensureTraceId, logError, logInfo, newRequestId } from './logger' | ||
| import * as safeStorage from './safeStorage' | ||
| const API_BASE_URL = process.env.API_URL || 'https://wumingmud.xxooz.com/api' | ||
| export class AuthRequestError extends Error { | ||
| kind: 'network' | 'timeout' | 'http' | 'parse' | 'auth' | 'protocol' | ||
| statusCode?: number | ||
| traceId: string | ||
| constructor( | ||
| message: string, | ||
| kind: 'network' | 'timeout' | 'http' | 'parse' | 'auth' | 'protocol', | ||
| traceId: string, | ||
| statusCode?: number | ||
| ) { | ||
| super(message) | ||
| this.name = 'AuthRequestError' | ||
| this.kind = kind | ||
| this.traceId = traceId | ||
| this.statusCode = statusCode | ||
| } | ||
| } | ||
| async function apiRequest<T>(endpoint: string, method: string, body?: any): Promise<T> { | ||
| const traceId = ensureTraceId() | ||
| const requestId = newRequestId() | ||
| const url = `${API_BASE_URL}${endpoint}` | ||
| const start = Date.now() | ||
| logInfo('auth.http.request', { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'auth_http', | ||
| endpoint, | ||
| method, | ||
| }) | ||
| let response: Response | ||
| try { | ||
| response = await fetch(url, { | ||
| method, | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-Trace-ID': traceId, | ||
| 'X-Request-ID': requestId, | ||
| }, | ||
| body: body ? JSON.stringify(body) : undefined, | ||
| }) | ||
| } catch (err) { | ||
| const wrapped = new AuthRequestError('网络连接失败', 'network', traceId) | ||
| logError('auth.http.error', err, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'auth_http', | ||
| endpoint, | ||
| error_kind: 'network', | ||
| duration_ms: Date.now() - start, | ||
| }) | ||
| throw wrapped | ||
| } | ||
| let data: any | ||
| try { | ||
| data = await response.json() | ||
| } catch (err) { | ||
| const wrapped = new AuthRequestError('服务返回了无效响应', 'parse', traceId, response.status) | ||
| logError('auth.http.error', err, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'auth_http', | ||
| endpoint, | ||
| status_code: response.status, | ||
| error_kind: 'parse', | ||
| response_content_type: response.headers.get('content-type') || '', | ||
| duration_ms: Date.now() - start, | ||
| }) | ||
| throw wrapped | ||
| } | ||
| logInfo('auth.http.response', { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'auth_http', | ||
| endpoint, | ||
| status_code: response.status, | ||
| duration_ms: Date.now() - start, | ||
| }) | ||
| if (!response.ok || !data.success) { | ||
| const message = data.message || data.error || 'API request failed' | ||
| const wrapped = new AuthRequestError(message, response.ok ? 'auth' : 'http', traceId, response.status) | ||
| logError('auth.http.error', message, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'auth_http', | ||
| endpoint, | ||
| status_code: response.status, | ||
| error_kind: response.ok ? 'auth' : 'http', | ||
| duration_ms: Date.now() - start, | ||
| }) | ||
| throw wrapped | ||
| } | ||
| return data | ||
| } | ||
| export async function login(credentials: LoginRequest): Promise<AuthResponse> { | ||
| const response = await apiRequest<AuthResponse>('/auth/login', 'POST', credentials) | ||
| if (response.success && response.data?.token) { | ||
| safeStorage.setItem('token', response.data.token) | ||
| safeStorage.setItem('player', JSON.stringify(response.data.player)) | ||
| logInfo('auth.login.success', { | ||
| trace_id: ensureTraceId(), | ||
| phase: 'auth_http', | ||
| player_id: response.data.player.id, | ||
| }) | ||
| } | ||
| return response | ||
| } | ||
| export async function register(data: RegisterRequest): Promise<AuthResponse> { | ||
| const response = await apiRequest<AuthResponse>('/auth/register', 'POST', data) | ||
| if (response.success && response.data?.token) { | ||
| safeStorage.setItem('token', response.data.token) | ||
| safeStorage.setItem('player', JSON.stringify(response.data.player)) | ||
| logInfo('auth.register.success', { | ||
| trace_id: ensureTraceId(), | ||
| phase: 'auth_http', | ||
| player_id: response.data.player.id, | ||
| }) | ||
| } | ||
| return response | ||
| } | ||
| export function logout(): void { | ||
| safeStorage.removeItem('token') | ||
| safeStorage.removeItem('player') | ||
| } | ||
| export function getToken(): string | null { | ||
| return safeStorage.getItem('token') | ||
| } | ||
| export function isLoggedIn(): boolean { | ||
| return !!getToken() | ||
| } |
| import { appendFileSync, mkdirSync } from 'node:fs' | ||
| import { dirname, join } from 'node:path' | ||
| type LogLevel = 'info' | 'error' | ||
| export interface LogFields { | ||
| trace_id?: string | ||
| request_id?: string | ||
| phase?: string | ||
| endpoint?: string | ||
| ws_path?: string | ||
| message_type?: string | ||
| status_code?: number | ||
| duration_ms?: number | ||
| error_kind?: 'network' | 'timeout' | 'http' | 'parse' | 'auth' | 'protocol' | ||
| [key: string]: unknown | ||
| } | ||
| const REDACT_KEYS = ['password', 'token', 'authorization', 'api_key'] | ||
| let currentTraceId: string | null = null | ||
| const LOG_PATH = process.env.CLIENT_LOG_PATH || join(process.cwd(), 'logs', 'client.log') | ||
| const LOG_STDOUT = process.env.CLIENT_LOG_STDOUT === 'true' | ||
| function nowISO(): string { | ||
| return new Date().toISOString() | ||
| } | ||
| function newID(): string { | ||
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { | ||
| return crypto.randomUUID() | ||
| } | ||
| return `${Date.now()}-${Math.random().toString(16).slice(2)}` | ||
| } | ||
| function redactValue(value: unknown): unknown { | ||
| if (typeof value !== 'string') return value | ||
| if (value.length <= 8) return '***' | ||
| return `${value.slice(0, 4)}...${value.slice(-4)}` | ||
| } | ||
| function sanitize(obj: Record<string, unknown>): Record<string, unknown> { | ||
| const output: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(obj)) { | ||
| const lower = key.toLowerCase() | ||
| if (REDACT_KEYS.some((sensitive) => lower.includes(sensitive))) { | ||
| output[key] = redactValue(value) | ||
| continue | ||
| } | ||
| output[key] = value | ||
| } | ||
| return output | ||
| } | ||
| function emit(level: LogLevel, event: string, fields: LogFields = {}): void { | ||
| const entry = sanitize({ | ||
| ts: nowISO(), | ||
| level, | ||
| event, | ||
| ...fields, | ||
| }) | ||
| const line = JSON.stringify(entry) | ||
| try { | ||
| mkdirSync(dirname(LOG_PATH), { recursive: true }) | ||
| appendFileSync(LOG_PATH, `${line}\n`, 'utf8') | ||
| } catch { | ||
| // Ignore log sink failures to keep TUI responsive. | ||
| } | ||
| if (LOG_STDOUT) { | ||
| if (level === 'error') { | ||
| console.error(line) | ||
| } else { | ||
| console.log(line) | ||
| } | ||
| } | ||
| } | ||
| function serializeUnknown(value: unknown): string { | ||
| if (typeof value === 'string') return value | ||
| try { | ||
| return JSON.stringify(value) | ||
| } catch { | ||
| return String(value) | ||
| } | ||
| } | ||
| export function beginTrace(): string { | ||
| currentTraceId = newID() | ||
| return currentTraceId | ||
| } | ||
| export function getTraceId(): string | null { | ||
| return currentTraceId | ||
| } | ||
| export function ensureTraceId(): string { | ||
| return currentTraceId || beginTrace() | ||
| } | ||
| export function clearTraceId(): void { | ||
| currentTraceId = null | ||
| } | ||
| export function newRequestId(): string { | ||
| return newID() | ||
| } | ||
| export function shortTraceId(traceId?: string | null): string { | ||
| if (!traceId) return '' | ||
| return traceId.slice(0, 8) | ||
| } | ||
| export function logInfo(event: string, fields: LogFields = {}): void { | ||
| emit('info', event, fields) | ||
| } | ||
| export function logError(event: string, err: unknown, fields: LogFields = {}): void { | ||
| const details: Record<string, unknown> = { | ||
| ...fields, | ||
| } | ||
| if (err instanceof Error) { | ||
| details.error_name = err.name | ||
| details.error_message = err.message | ||
| } else if (typeof err === 'string') { | ||
| details.error_message = err | ||
| } else { | ||
| details.error_message = 'non-error rejection' | ||
| details.error_value = serializeUnknown(err) | ||
| } | ||
| emit('error', event, details) | ||
| } |
| import type { ParsedCommand } from '../types/api' | ||
| export function parseCommand(input: string): ParsedCommand { | ||
| const trimmed = input.trim() | ||
| if (!trimmed) { | ||
| return { | ||
| type: 'empty', | ||
| data: {} | ||
| } | ||
| } | ||
| const firstChar = trimmed.charAt(0) | ||
| if (firstChar === '!' || firstChar === '/') { | ||
| const parts = trimmed.slice(1).trim().split(/\s+/) | ||
| const command = parts[0]?.toLowerCase() | ||
| const args = parts.slice(1) | ||
| switch (command) { | ||
| case 'go': | ||
| case 'n': | ||
| case 'north': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'north' } | ||
| } | ||
| case 's': | ||
| case 'south': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'south' } | ||
| } | ||
| case 'e': | ||
| case 'east': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'east' } | ||
| } | ||
| case 'w': | ||
| case 'west': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'west' } | ||
| } | ||
| case 'u': | ||
| case 'up': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'up' } | ||
| } | ||
| case 'd': | ||
| case 'down': | ||
| return { | ||
| type: 'move', | ||
| data: { direction: 'down' } | ||
| } | ||
| case 'look': | ||
| case 'l': | ||
| return { | ||
| type: 'look', | ||
| data: {} | ||
| } | ||
| case 'say': | ||
| return { | ||
| type: 'chat', | ||
| data: { | ||
| channel: 'room', | ||
| content: args.join(' ') | ||
| } | ||
| } | ||
| case 'tell': | ||
| if (args.length >= 2) { | ||
| const target = args[0] | ||
| const content = args.slice(1).join(' ') | ||
| return { | ||
| type: 'chat', | ||
| data: { | ||
| channel: 'private', | ||
| target, | ||
| content | ||
| } | ||
| } | ||
| } | ||
| break | ||
| case 'guild': | ||
| case 'g': | ||
| return { | ||
| type: 'chat', | ||
| data: { | ||
| channel: 'guild', | ||
| content: args.join(' ') | ||
| } | ||
| } | ||
| case 'attack': | ||
| case 'kill': | ||
| if (args.length > 0) { | ||
| return { | ||
| type: 'combat_attack', | ||
| data: { | ||
| target: args[0], | ||
| skill: args[1] || 'normal_attack' | ||
| } | ||
| } | ||
| } | ||
| break | ||
| case 'quest': | ||
| case 'q': | ||
| if (args.length === 0) { | ||
| return { | ||
| type: 'quest_list', | ||
| data: {} | ||
| } | ||
| } | ||
| if (args[0] === 'accept' && args[1]) { | ||
| return { | ||
| type: 'quest_accept', | ||
| data: { quest_id: args[1] } | ||
| } | ||
| } | ||
| break | ||
| case 'help': | ||
| case 'h': | ||
| return { | ||
| type: 'help', | ||
| data: {} | ||
| } | ||
| case 'who': | ||
| return { | ||
| type: 'who', | ||
| data: {} | ||
| } | ||
| case 'inventory': | ||
| case 'inv': | ||
| case 'i': | ||
| return { | ||
| type: 'inventory', | ||
| data: {} | ||
| } | ||
| case 'status': | ||
| case 'stat': | ||
| return { | ||
| type: 'status', | ||
| data: {} | ||
| } | ||
| default: | ||
| return { | ||
| type: 'unknown_command', | ||
| data: { command } | ||
| } | ||
| } | ||
| } | ||
| // Non-slash inputs are treated as natural language and interpreted by backend. | ||
| return { | ||
| type: 'player_input', | ||
| data: { | ||
| text: trimmed | ||
| } | ||
| } | ||
| } | ||
| export function isMovementInput(input: string): boolean { | ||
| const trimmed = input.trim().toLowerCase() | ||
| const movementCommands = ['n', 'north', 's', 'south', 'e', 'east', 'w', 'west', 'u', 'up', 'd', 'down'] | ||
| return movementCommands.includes(trimmed) | ||
| } | ||
| export function isChatInput(input: string): boolean { | ||
| return input.trim().startsWith('"') | ||
| } | ||
| export function isCommandInput(input: string): boolean { | ||
| const trimmed = input.trim() | ||
| return trimmed.startsWith('!') || trimmed.startsWith('/') | ||
| } |
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | ||
| import { dirname, join } from 'node:path' | ||
| import { homedir } from 'node:os' | ||
| interface StorageLike { | ||
| getItem(key: string): string | null | ||
| setItem(key: string, value: string): void | ||
| removeItem(key: string): void | ||
| } | ||
| class FileStorage implements StorageLike { | ||
| private filePath: string | ||
| private data: Record<string, string> | ||
| constructor(filePath: string) { | ||
| this.filePath = filePath | ||
| this.data = this.load() | ||
| } | ||
| private load(): Record<string, string> { | ||
| try { | ||
| if (!existsSync(this.filePath)) { | ||
| return {} | ||
| } | ||
| const raw = readFileSync(this.filePath, 'utf8') | ||
| const parsed = JSON.parse(raw) | ||
| if (!parsed || typeof parsed !== 'object') { | ||
| return {} | ||
| } | ||
| return parsed as Record<string, string> | ||
| } catch { | ||
| return {} | ||
| } | ||
| } | ||
| private save(): void { | ||
| try { | ||
| mkdirSync(dirname(this.filePath), { recursive: true }) | ||
| writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf8') | ||
| } catch { | ||
| // Ignore storage persistence failure to avoid breaking TUI flow. | ||
| } | ||
| } | ||
| getItem(key: string): string | null { | ||
| return Object.prototype.hasOwnProperty.call(this.data, key) ? this.data[key] : null | ||
| } | ||
| setItem(key: string, value: string): void { | ||
| this.data[key] = value | ||
| this.save() | ||
| } | ||
| removeItem(key: string): void { | ||
| delete this.data[key] | ||
| this.save() | ||
| } | ||
| } | ||
| function resolveStorage(): StorageLike { | ||
| const g = globalThis as typeof globalThis & { localStorage?: StorageLike } | ||
| if (g.localStorage) { | ||
| return g.localStorage | ||
| } | ||
| const filePath = process.env.CLIENT_STORAGE_PATH || join(homedir(), '.wumingmud', 'client-storage.json') | ||
| return new FileStorage(filePath) | ||
| } | ||
| const storage = resolveStorage() | ||
| export function getItem(key: string): string | null { | ||
| return storage.getItem(key) | ||
| } | ||
| export function setItem(key: string, value: string): void { | ||
| storage.setItem(key, value) | ||
| } | ||
| export function removeItem(key: string): void { | ||
| storage.removeItem(key) | ||
| } |
| import WebSocket from 'ws' | ||
| import type { ClientMessage, ServerMessage } from '../types/message' | ||
| import type { ConnectionStatus } from '../types/state' | ||
| import { ensureTraceId, logError, logInfo, newRequestId } from './logger' | ||
| import { getItem } from './safeStorage' | ||
| const WS_URL = process.env.WS_URL || 'wss://wumingmud.xxooz.com/ws' | ||
| const HEARTBEAT_INTERVAL = 30000 | ||
| class WebSocketService { | ||
| private ws: WebSocket | null = null | ||
| private heartbeatInterval: NodeJS.Timeout | null = null | ||
| private connectInFlight: Promise<void> | null = null | ||
| private messageHandlers: ((message: ServerMessage) => void)[] = [] | ||
| private statusHandlers: ((status: ConnectionStatus) => void)[] = [] | ||
| connect(token?: string): Promise<void> { | ||
| if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) { | ||
| return Promise.resolve() | ||
| } | ||
| if (this.connectInFlight) { | ||
| return this.connectInFlight | ||
| } | ||
| this.notifyStatus('connecting') | ||
| this.connectInFlight = new Promise((resolve, reject) => { | ||
| const traceId = ensureTraceId() | ||
| const requestId = newRequestId() | ||
| const start = Date.now() | ||
| try { | ||
| const authToken = token || getItem('token') | ||
| if (!authToken) { | ||
| this.notifyStatus('error') | ||
| const err = new Error('No token available') | ||
| logError('ws.connect.fail', err, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'ws_connect', | ||
| error_kind: 'auth', | ||
| }) | ||
| reject(err) | ||
| return | ||
| } | ||
| const url = `${WS_URL}?token=${encodeURIComponent(authToken)}&trace_id=${encodeURIComponent(traceId)}` | ||
| logInfo('ws.connect.start', { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'ws_connect', | ||
| ws_path: WS_URL, | ||
| }) | ||
| this.ws = new WebSocket(url) | ||
| this.ws.onopen = () => { | ||
| logInfo('ws.connect.success', { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'ws_connect', | ||
| duration_ms: Date.now() - start, | ||
| }) | ||
| this.notifyStatus('connected') | ||
| this.startHeartbeat() | ||
| this.connectInFlight = null | ||
| resolve() | ||
| } | ||
| this.ws.onmessage = (event) => { | ||
| try { | ||
| const rawData = event?.data | ||
| const payload = rawData == null | ||
| ? '' | ||
| : Buffer.isBuffer(rawData) | ||
| ? rawData.toString('utf8') | ||
| : typeof rawData === 'string' | ||
| ? rawData | ||
| : String(rawData) | ||
| const message: ServerMessage = JSON.parse(payload) | ||
| logInfo('ws.message.in', { | ||
| trace_id: message.trace_id || traceId, | ||
| request_id: message.request_id, | ||
| phase: 'ws_message', | ||
| message_type: message.type, | ||
| payload_size: payload.length, | ||
| }) | ||
| this.messageHandlers.forEach(handler => handler(message)) | ||
| } catch (err) { | ||
| logError('ws.message.parse_error', err, { | ||
| trace_id: traceId, | ||
| phase: 'ws_message', | ||
| error_kind: 'parse', | ||
| }) | ||
| } | ||
| } | ||
| this.ws.onclose = (event) => { | ||
| this.stopHeartbeat() | ||
| logInfo('ws.connect.close', { | ||
| trace_id: traceId, | ||
| phase: 'ws_connect', | ||
| code: event.code, | ||
| reason: event.reason, | ||
| }) | ||
| this.notifyStatus('disconnected') | ||
| this.connectInFlight = null | ||
| } | ||
| this.ws.onerror = (error) => { | ||
| logError('ws.connect.error', error, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'ws_connect', | ||
| error_kind: 'network', | ||
| }) | ||
| this.notifyStatus('error') | ||
| this.connectInFlight = null | ||
| reject(error) | ||
| } | ||
| } catch (err) { | ||
| logError('ws.connect.exception', err, { | ||
| trace_id: traceId, | ||
| request_id: requestId, | ||
| phase: 'ws_connect', | ||
| error_kind: 'network', | ||
| }) | ||
| this.notifyStatus('error') | ||
| this.connectInFlight = null | ||
| reject(err) | ||
| } | ||
| }) | ||
| return this.connectInFlight | ||
| } | ||
| disconnect(): void { | ||
| this.stopHeartbeat() | ||
| this.connectInFlight = null | ||
| if (this.ws) { | ||
| this.ws.close() | ||
| this.ws = null | ||
| } | ||
| } | ||
| send(message: ClientMessage): boolean { | ||
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { | ||
| return false | ||
| } | ||
| try { | ||
| const enrichedMessage: ClientMessage = { | ||
| ...message, | ||
| trace_id: message.trace_id || ensureTraceId(), | ||
| request_id: message.request_id || newRequestId(), | ||
| } | ||
| this.ws.send(JSON.stringify(enrichedMessage)) | ||
| logInfo('ws.message.out', { | ||
| trace_id: enrichedMessage.trace_id, | ||
| request_id: enrichedMessage.request_id, | ||
| phase: 'ws_message', | ||
| message_type: enrichedMessage.type, | ||
| }) | ||
| return true | ||
| } catch (err) { | ||
| logError('ws.message.send_error', err, { | ||
| trace_id: ensureTraceId(), | ||
| phase: 'ws_message', | ||
| }) | ||
| return false | ||
| } | ||
| } | ||
| onMessage(handler: (message: ServerMessage) => void): () => void { | ||
| this.messageHandlers.push(handler) | ||
| return () => { | ||
| this.messageHandlers = this.messageHandlers.filter(h => h !== handler) | ||
| } | ||
| } | ||
| onStatusChange(handler: (status: ConnectionStatus) => void): () => void { | ||
| this.statusHandlers.push(handler) | ||
| return () => { | ||
| this.statusHandlers = this.statusHandlers.filter(h => h !== handler) | ||
| } | ||
| } | ||
| getConnectionStatus(): ConnectionStatus { | ||
| if (!this.ws) return 'disconnected' | ||
| switch (this.ws.readyState) { | ||
| case WebSocket.CONNECTING: | ||
| return 'connecting' | ||
| case WebSocket.OPEN: | ||
| return 'connected' | ||
| case WebSocket.CLOSING: | ||
| case WebSocket.CLOSED: | ||
| return 'disconnected' | ||
| default: | ||
| return 'disconnected' | ||
| } | ||
| } | ||
| private startHeartbeat(): void { | ||
| this.stopHeartbeat() | ||
| this.heartbeatInterval = setInterval(() => { | ||
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | ||
| this.send({ | ||
| type: 'ping', | ||
| timestamp: Date.now(), | ||
| data: {} | ||
| }) | ||
| } | ||
| }, HEARTBEAT_INTERVAL) | ||
| } | ||
| private stopHeartbeat(): void { | ||
| if (this.heartbeatInterval) { | ||
| clearInterval(this.heartbeatInterval) | ||
| this.heartbeatInterval = null | ||
| } | ||
| } | ||
| private notifyStatus(status: ConnectionStatus): void { | ||
| this.statusHandlers.forEach(handler => handler(status)) | ||
| } | ||
| } | ||
| export const wsService = new WebSocketService() |
| import { create } from 'zustand' | ||
| import type { | ||
| Player, | ||
| Room, | ||
| ChatMessage, | ||
| WorldEvent, | ||
| CombatState, | ||
| ConnectionStatus, | ||
| Notification, | ||
| ChatChannel, | ||
| } from '../types/state' | ||
| interface GameState { | ||
| isAuthenticated: boolean | ||
| setAuthenticated: (value: boolean) => void | ||
| connectionStatus: ConnectionStatus | ||
| setConnectionStatus: (status: ConnectionStatus) => void | ||
| onlineCount: number | ||
| setOnlineCount: (count: number) => void | ||
| player: Player | null | ||
| setPlayer: (player: Player | null) => void | ||
| updatePlayer: (updates: Partial<Player>) => void | ||
| currentRoom: Room | null | ||
| updateRoom: (room: Room) => void | ||
| onlinePlayers: Player[] | ||
| setOnlinePlayers: (players: Player[]) => void | ||
| npcsInRoom: Room['npcs'] | ||
| setNpcsInRoom: (npcs: Room['npcs']) => void | ||
| worldEvents: WorldEvent[] | ||
| addWorldEvent: (event: WorldEvent) => void | ||
| chatMessages: ChatMessage[] | ||
| addChatMessage: (message: ChatMessage) => void | ||
| activeChatTab: ChatChannel | ||
| cycleChatTab: (reverse?: boolean) => void | ||
| combatState: CombatState | null | ||
| updateCombat: (combatState: CombatState) => void | ||
| clearCombat: () => void | ||
| notifications: Notification[] | ||
| addNotification: (notification: Notification) => void | ||
| removeNotification: (id: string) => void | ||
| } | ||
| export const useStore = create<GameState>((set) => ({ | ||
| isAuthenticated: false, | ||
| connectionStatus: 'disconnected', | ||
| onlineCount: 0, | ||
| player: null, | ||
| currentRoom: null, | ||
| onlinePlayers: [], | ||
| npcsInRoom: [], | ||
| worldEvents: [], | ||
| chatMessages: [], | ||
| activeChatTab: 'room', | ||
| combatState: null, | ||
| notifications: [], | ||
| setAuthenticated: (value) => set({ isAuthenticated: value }), | ||
| setConnectionStatus: (status) => set({ connectionStatus: status }), | ||
| setOnlineCount: (count) => set({ onlineCount: count }), | ||
| setPlayer: (player) => set({ player }), | ||
| updatePlayer: (updates) => set((state) => ({ | ||
| player: state.player ? { ...state.player, ...updates } : null | ||
| })), | ||
| updateRoom: (room) => { | ||
| const normalizedRoom = { | ||
| ...room, | ||
| description: room.description ?? '', | ||
| players: Array.isArray(room.players) ? room.players : [], | ||
| npcs: Array.isArray(room.npcs) ? room.npcs : [], | ||
| exits: Array.isArray(room.exits) ? room.exits : [], | ||
| } | ||
| set({ | ||
| currentRoom: normalizedRoom, | ||
| onlinePlayers: normalizedRoom.players, | ||
| npcsInRoom: normalizedRoom.npcs, | ||
| }) | ||
| }, | ||
| setOnlinePlayers: (players) => set({ onlinePlayers: players }), | ||
| setNpcsInRoom: (npcs) => set({ npcsInRoom: npcs }), | ||
| addWorldEvent: (event) => set((state) => ({ | ||
| worldEvents: [...state.worldEvents.slice(-99), event] | ||
| })), | ||
| addChatMessage: (message) => set((state) => ({ | ||
| chatMessages: [...state.chatMessages.slice(-199), message] | ||
| })), | ||
| cycleChatTab: (reverse = false) => set((state) => { | ||
| const tabs: ChatChannel[] = ['room', 'guild', 'private'] | ||
| const currentIndex = tabs.indexOf(state.activeChatTab) | ||
| const index = currentIndex < 0 ? 0 : currentIndex | ||
| const nextIndex = reverse | ||
| ? (index - 1 + tabs.length) % tabs.length | ||
| : (index + 1) % tabs.length | ||
| return { activeChatTab: tabs[nextIndex] } | ||
| }), | ||
| updateCombat: (combatState) => set({ combatState }), | ||
| clearCombat: () => set({ combatState: null }), | ||
| addNotification: (notification) => set((state) => ({ | ||
| notifications: [...state.notifications, notification] | ||
| })), | ||
| removeNotification: (id) => set((state) => ({ | ||
| notifications: state.notifications.filter(n => n.id !== id) | ||
| })), | ||
| })) |
| export interface LoginRequest { | ||
| username: string | ||
| password: string | ||
| } | ||
| export interface RegisterRequest { | ||
| username: string | ||
| password: string | ||
| name: string | ||
| } | ||
| export interface AuthResponse { | ||
| success: boolean | ||
| data?: { | ||
| token: string | ||
| expires_in: number | ||
| player: { | ||
| id: string | ||
| name: string | ||
| level: number | ||
| } | ||
| } | ||
| error?: string | ||
| message?: string | ||
| } | ||
| export interface ErrorResponse { | ||
| success: false | ||
| error: string | ||
| message: string | ||
| } | ||
| export interface ParsedCommand { | ||
| type: string | ||
| data: any | ||
| } |
| export interface ClientMessage { | ||
| type: string | ||
| timestamp: number | ||
| data: any | ||
| trace_id?: string | ||
| request_id?: string | ||
| } | ||
| export interface ServerMessage { | ||
| type: string | ||
| timestamp: number | ||
| data: any | ||
| trace_id?: string | ||
| request_id?: string | ||
| } | ||
| export type MessageType = | ||
| | 'auth_ok' | ||
| | 'auth_failed' | ||
| | 'room_update' | ||
| | 'player_update' | ||
| | 'chat' | ||
| | 'world_event' | ||
| | 'combat_start' | ||
| | 'combat_round' | ||
| | 'combat_end' | ||
| | 'error' | ||
| | 'ping' | ||
| | 'pong' | ||
| | 'player_input' | ||
| | 'move' | ||
| | 'combat_attack' | ||
| | 'pvp_challenge' | ||
| | 'pvp_response' | ||
| | 'npc_talk' | ||
| | 'quest_accept' | ||
| | 'quest_list' | ||
| | 'guild_create' | ||
| | 'guild_join' | ||
| | 'guild_leave' | ||
| | 'pvp_challenge_received' | ||
| | 'npc_dialogue' | ||
| | 'quest_update' | ||
| | 'quest_complete' | ||
| | 'guild_notification' | ||
| | 'online_update' | ||
| | 'quest_update' | ||
| | 'npc_talk' |
| export interface Player { | ||
| id: string | ||
| name: string | ||
| level: number | ||
| hp: number | ||
| max_hp: number | ||
| mp: number | ||
| max_mp: number | ||
| exp: number | ||
| faction_id?: string | ||
| guild_id?: string | ||
| location_id: string | ||
| gold: number | ||
| } | ||
| export interface NPC { | ||
| id: string | ||
| name: string | ||
| description?: string | ||
| } | ||
| export interface Exit { | ||
| direction: string | ||
| name: string | ||
| target: string | ||
| } | ||
| export interface Room { | ||
| id: string | ||
| name: string | ||
| description: string | ||
| npcs: NPC[] | ||
| players: Player[] | ||
| exits: Exit[] | ||
| } | ||
| export interface CombatState { | ||
| combat_id: string | ||
| type: 'pve' | 'pvp' | ||
| opponent: { | ||
| id: string | ||
| name: string | ||
| hp: number | ||
| max_hp: number | ||
| } | ||
| narrative?: string | ||
| round?: number | ||
| result?: 'victory' | 'defeat' | 'flee' | ||
| rewards?: { | ||
| exp: number | ||
| gold: number | ||
| items: any[] | ||
| } | ||
| } | ||
| export interface WorldEvent { | ||
| id: string | ||
| type: 'system' | 'world' | 'combat' | 'narrative' | ||
| title?: string | ||
| content: string | ||
| timestamp: number | ||
| importance?: 'low' | 'normal' | 'high' | ||
| } | ||
| export type ChatChannel = 'room' | 'guild' | 'private' | 'system' | ||
| export interface ChatMessage { | ||
| id: string | ||
| type: ChatChannel | ||
| sender: { | ||
| id: string | ||
| name: string | ||
| } | ||
| content: string | ||
| timestamp: number | ||
| } | ||
| export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' | 'error' | ||
| export type AuthStatus = 'authenticated' | 'unauthenticated' | 'pending' | ||
| export interface Notification { | ||
| id: string | ||
| type: 'success' | 'error' | 'warning' | 'info' | ||
| message: string | ||
| duration?: number | ||
| } |
| export const errorToNarrative = (error: string): string => { | ||
| const narratives: Record<string, string> = { | ||
| 'connection_failed': '你感觉真气不畅,无法与江湖建立联系...', | ||
| 'auth_failed': '你的江湖令似乎出了问题,需要重新验证身份...', | ||
| 'timeout': '四周云雾缭绕,你暂时看不清方向...', | ||
| 'server_error': '天地元气震荡,似乎有什么变故发生...', | ||
| 'invalid_command': '你一时恍惚,不知该如何是好...', | ||
| 'move_failed': '这个方向没有路。', | ||
| 'move_blocked': '有人挡住了你的去路。', | ||
| 'move_combat': '你正在战斗中,无法移动。', | ||
| 'combat_target_invalid': '目标不存在。', | ||
| 'combat_in_progress': '你已经在战斗中了。', | ||
| 'combat_not_in_range': '目标太远了。', | ||
| 'pvp_target_offline': '对方不在江湖中。', | ||
| 'pvp_in_safe_zone': '这里是安全区,禁止斗殴。', | ||
| 'pvp_level_diff': '对方实力与你相差悬殊。', | ||
| 'pvp_target_fighting': '对方正在战斗中。', | ||
| 'guild_name_exists': '该帮派名已被使用。', | ||
| 'guild_not_found': '找不到该帮派。', | ||
| 'guild_full': '该帮派人数已满。', | ||
| 'guild_level_low': '你的等级不足以创建帮派。', | ||
| 'guild_not_enough_gold': '你的金币不足以创建帮派。', | ||
| 'quest_not_found': '该任务不存在。', | ||
| 'quest_prerequisites': '你还不能接取这个任务。', | ||
| 'quest_already_active': '你已经接受了这个任务。', | ||
| 'quest_already_completed': '你已经完成过这个任务了。', | ||
| } | ||
| return narratives[error] || '江湖中传来一阵莫名的波动...' | ||
| } | ||
| export const statusToNarrative = (status: string): string => { | ||
| const narratives: Record<string, string> = { | ||
| 'connecting': '你正在尝试进入江湖...', | ||
| 'connected': '你已成功踏入江湖世界。', | ||
| 'disconnected': '你感觉与外界的联系中断了...', | ||
| 'reconnecting': '你正在努力恢复与江湖的联系...', | ||
| } | ||
| return narratives[status] || status | ||
| } | ||
| export const getReconnectNarrative = (retryCount: number): string => { | ||
| const narratives = [ | ||
| '你感觉真气不畅,正在调息恢复...', | ||
| '四周云雾缭绕,视线受阻...', | ||
| '你正在努力冲破穴道封印...', | ||
| '天地元气震荡,你正在适应...', | ||
| ] | ||
| return narratives[retryCount % narratives.length] | ||
| } | ||
| export const formatTime = (timestamp: number): string => { | ||
| return new Date(timestamp).toLocaleTimeString('zh-CN', { | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| second: '2-digit' | ||
| }) | ||
| } |
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "NodeNext", | ||
| "moduleResolution": "NodeNext", | ||
| "lib": ["ES2022"], | ||
| "jsx": "react-jsx", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "forceConsistentCasingInFileNames": true, | ||
| "resolveJsonModule": true, | ||
| "declaration": true, | ||
| "outDir": "./dist", | ||
| "rootDir": "./src" | ||
| }, | ||
| "include": ["src/**/*"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
88
7.32%10
-41.18%5
-16.67%663893
-7.96%3
-89.66%2681
-41.62%