🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

wumingmud

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

wumingmud - npm Package Compare versions

Comparing version
0.1.0
to
0.1.1
+7
-1
package.json
{
"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"]
}