Compare commits
8 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21caa974e | ||
|
|
a08a84b7db | ||
|
|
449bd420a6 | ||
|
|
1f297ac769 | ||
|
|
4cf868a769 | ||
|
|
afdeb00b4d | ||
|
|
764ce67742 | ||
|
|
61a13baca2 |
20
README.md
20
README.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **Timer** | Timer and cooldown systems | No |
|
||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||
| **Network** | Client/server networking with TSRPC | No |
|
||||
| **Procgen** | Procedural generation (noise, random, sampling) | No |
|
||||
| **RPC** | High-performance RPC communication framework | No |
|
||||
| **Server** | Game server framework with rooms, auth, rate limiting | No |
|
||||
| **Network** | Client networking with prediction, AOI, delta compression | No |
|
||||
| **Transaction** | Game transaction system with Redis/Memory storage | No |
|
||||
| **World Streaming** | Open world chunk loading and streaming | No |
|
||||
|
||||
> All framework modules can be used standalone with any rendering engine.
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
|
||||
npm install @esengine/timer # Timers & cooldowns
|
||||
npm install @esengine/spatial # Spatial indexing
|
||||
npm install @esengine/pathfinding # Pathfinding
|
||||
npm install @esengine/network # Networking
|
||||
npm install @esengine/procgen # Procedural generation
|
||||
npm install @esengine/rpc # RPC framework
|
||||
npm install @esengine/server # Game server
|
||||
npm install @esengine/network # Client networking
|
||||
npm install @esengine/transaction # Transaction system
|
||||
npm install @esengine/world-streaming # World streaming
|
||||
```
|
||||
|
||||
### ESEngine Runtime (Optional)
|
||||
@@ -235,7 +245,11 @@ esengine/
|
||||
│ │ ├── spatial/ # Spatial queries
|
||||
│ │ ├── pathfinding/ # Pathfinding
|
||||
│ │ ├── procgen/ # Procedural generation
|
||||
│ │ └── network/ # Networking
|
||||
│ │ ├── rpc/ # RPC framework
|
||||
│ │ ├── server/ # Game server
|
||||
│ │ ├── network/ # Client networking
|
||||
│ │ ├── transaction/ # Transaction system
|
||||
│ │ └── world-streaming/ # World streaming
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine runtime
|
||||
│ ├── rendering/ # Rendering modules
|
||||
|
||||
20
README_CN.md
20
README_CN.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **定时器** | 定时器和冷却系统 | 否 |
|
||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
||||
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
|
||||
| **RPC** | 高性能 RPC 通信框架 | 否 |
|
||||
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
|
||||
| **网络** | 客户端网络,支持预测、AOI、增量压缩 | 否 |
|
||||
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
|
||||
| **世界流送** | 开放世界分块加载和流送 | 否 |
|
||||
|
||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
|
||||
npm install @esengine/timer # 定时器和冷却
|
||||
npm install @esengine/spatial # 空间索引
|
||||
npm install @esengine/pathfinding # 寻路
|
||||
npm install @esengine/network # 网络
|
||||
npm install @esengine/procgen # 程序化生成
|
||||
npm install @esengine/rpc # RPC 框架
|
||||
npm install @esengine/server # 游戏服务器
|
||||
npm install @esengine/network # 客户端网络
|
||||
npm install @esengine/transaction # 事务系统
|
||||
npm install @esengine/world-streaming # 世界流送
|
||||
```
|
||||
|
||||
### ESEngine 运行时(可选)
|
||||
@@ -235,7 +245,11 @@ esengine/
|
||||
│ │ ├── spatial/ # 空间查询
|
||||
│ │ ├── pathfinding/ # 寻路
|
||||
│ │ ├── procgen/ # 程序化生成
|
||||
│ │ └── network/ # 网络
|
||||
│ │ ├── rpc/ # RPC 框架
|
||||
│ │ ├── server/ # 游戏服务器
|
||||
│ │ ├── network/ # 客户端网络
|
||||
│ │ ├── transaction/ # 事务系统
|
||||
│ │ └── world-streaming/ # 世界流送
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine 运行时
|
||||
│ ├── rendering/ # 渲染模块
|
||||
|
||||
@@ -267,6 +267,8 @@ export default defineConfig({
|
||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||
|
||||
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
title: "Authentication"
|
||||
description: "Add authentication to your game server with JWT and Session providers"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
|
||||
|
||||
## Installation
|
||||
|
||||
Authentication is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> Note: `jsonwebtoken` is an optional peer dependency, required only for JWT authentication.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// Create JWT provider
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 hour
|
||||
})
|
||||
|
||||
// Wrap server with authentication
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// Define authenticated room
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Auth Providers
|
||||
|
||||
### JWT Provider
|
||||
|
||||
Use JSON Web Tokens for stateless authentication:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// Required: secret key
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// Optional: algorithm (default: HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// Optional: expiration in seconds (default: 3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// Optional: issuer for validation
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// Optional: audience for validation
|
||||
audience: 'my-game-client',
|
||||
|
||||
// Optional: custom user extraction
|
||||
getUser: async (payload) => {
|
||||
// Fetch user from database
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// Sign a token (for login endpoints)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// Decode without verification (for debugging)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Session Provider
|
||||
|
||||
Use server-side sessions for stateful authentication:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// Custom storage implementation
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 hours in ms
|
||||
|
||||
// Optional: validate user on each request
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// Create session (for login endpoints)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// Revoke session (for logout)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## Server Auth Mixin
|
||||
|
||||
The `withAuth` function wraps your server to add authentication:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// Required: auth provider
|
||||
provider: jwtProvider,
|
||||
|
||||
// Required: extract credentials from request
|
||||
extractCredentials: (req) => {
|
||||
// From query string
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// Or from headers
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// Optional: handle auth failure
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`Auth failed: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Auth Context
|
||||
|
||||
After authentication, the auth context is available on connections:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`User ${auth.userId} connected`)
|
||||
console.log(`Roles: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Room Auth Mixin
|
||||
|
||||
The `withRoomAuth` function adds authentication checks to rooms:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// Require authentication to join
|
||||
requireAuth: true,
|
||||
|
||||
// Optional: require specific roles
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// Optional: role check mode ('any' or 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player has .auth and .user properties
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined`)
|
||||
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// Optional: custom auth validation
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// Additional validation logic
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? 'Guest',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer Interface
|
||||
|
||||
Players in auth rooms have additional properties:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// Full auth context
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// User info (shortcut for auth.user)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### Room Auth Helpers
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// Get player by user ID
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// Get all players with a role
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// Get player with auth info
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Decorators
|
||||
|
||||
### @requireAuth
|
||||
|
||||
Mark message handlers as requiring authentication:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// Only authenticated players can trade
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// Guests can also chat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
Require specific roles for message handlers:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// Only admins can ban
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// Moderators OR admins can mute
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// Requires BOTH verified AND premium roles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Context API
|
||||
|
||||
The auth context provides various methods for checking authentication state:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// Authentication state
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// Role checking
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
The `AuthContext` class (implementation) also provides:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// Set authentication from result
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// Clear authentication state
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the mock auth provider for unit tests:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// Create mock provider with preset users
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // Create users for unknown tokens
|
||||
})
|
||||
|
||||
// Use in tests
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// Verify with user ID as token
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// Add/remove users dynamically
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// Revoke tokens
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// Reset to initial state
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Auth errors include error codes for programmatic handling:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||
| 'EXPIRED_TOKEN' // Token has expired
|
||||
| 'USER_NOT_FOUND' // User lookup failed
|
||||
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||
| 'RATE_LIMITED' // Too many requests
|
||||
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||
|
||||
// In your auth failure handler
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example with JWT authentication:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// Types
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT Provider
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Create authenticated server
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Game Room with auth
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('Game room created')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// Handle movement
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, 'Kicked by admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||
|
||||
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||
|
||||
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||
|
||||
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||
|
||||
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||
|
||||
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||
|
||||
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "Rate Limiting"
|
||||
description: "Protect your game server from abuse with configurable rate limiting"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
|
||||
|
||||
## Installation
|
||||
|
||||
Rate limiting is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// Protected by rate limit (10 msg/s default)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// Stricter limit for trading
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// No rate limit for heartbeat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Strategies
|
||||
|
||||
### Token Bucket (Default)
|
||||
|
||||
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // Refill rate
|
||||
burstSize: 20, // Bucket capacity
|
||||
}) { }
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
```
|
||||
Config: rate=10/s, burstSize=20
|
||||
|
||||
[0s] Bucket full: 20 tokens
|
||||
[0s] 15 messages → allowed, 5 remaining
|
||||
[0.5s] Refill 5 tokens → 10 tokens
|
||||
[0.5s] 8 messages → allowed, 2 remaining
|
||||
[0.6s] Refill 1 token → 3 tokens
|
||||
[0.6s] 5 messages → 3 allowed, 2 rejected
|
||||
```
|
||||
|
||||
**Best for:** Most general use cases, balances burst tolerance with protection.
|
||||
|
||||
### Sliding Window
|
||||
|
||||
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** When you need precise rate limiting without burst tolerance.
|
||||
|
||||
### Fixed Window
|
||||
|
||||
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** Simple scenarios where boundary burst is acceptable.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Room Configuration
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// Messages allowed per second (default: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// Burst capacity / bucket size (default: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Callback when rate limited
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect on rate limit (default: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// Disconnect after N consecutive limits (0 = never)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// Custom key function (default: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// Cleanup interval in ms (default: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### Per-Message Configuration
|
||||
|
||||
Use decorators to configure rate limits for specific messages:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// Custom rate limit for this message
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// This message costs 5 tokens
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// Exempt from rate limiting
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// Alternative: specify message type explicitly
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining with Authentication
|
||||
|
||||
Rate limiting works seamlessly with the authentication system:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// Apply both mixins
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} joined with rate limit protection`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Result
|
||||
|
||||
When a message is rate limited, the callback receives a result object:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// Whether the request was allowed
|
||||
allowed: boolean
|
||||
|
||||
// Remaining quota
|
||||
remaining: number
|
||||
|
||||
// When the quota resets (timestamp)
|
||||
resetAt: number
|
||||
|
||||
// How long to wait before retrying (ms)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Rate Limit Context
|
||||
|
||||
You can access the rate limit context for any player:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// Check without consuming
|
||||
const status = context?.check()
|
||||
console.log(`Remaining: ${status?.remaining}`)
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Or use the standalone function
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## Custom Strategies
|
||||
|
||||
You can use the strategies directly for custom implementations:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// Create strategy directly
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // tokens per second
|
||||
capacity: 20, // max tokens
|
||||
})
|
||||
|
||||
// Check and consume
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// Process message
|
||||
} else {
|
||||
// Rate limited, wait result.retryAfter ms
|
||||
}
|
||||
|
||||
// Check without consuming
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// Reset a key
|
||||
strategy.reset('player-123')
|
||||
|
||||
// Cleanup expired records
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## Rate Limit Context
|
||||
|
||||
The `RateLimitContext` class manages rate limiting for a single player:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// Check without consuming
|
||||
context.check()
|
||||
|
||||
// Consume quota
|
||||
context.consume()
|
||||
|
||||
// Consume with cost
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// Consume for specific message type
|
||||
context.consume('Trade')
|
||||
|
||||
// Set per-message strategy
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// Reset
|
||||
context.reset()
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## Room Lifecycle Hook
|
||||
|
||||
You can override the `onRateLimited` hook for custom handling:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// Log the event
|
||||
console.log(`Player ${player.id} rate limited on ${messageType}`)
|
||||
|
||||
// Send custom error
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `Slow down! Try again in ${result.retryAfter}ms`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with token bucket**: It's the most flexible algorithm for games.
|
||||
|
||||
2. **Set appropriate limits**: Consider your game's mechanics:
|
||||
- Movement messages: Higher limits (20-60/s)
|
||||
- Chat messages: Lower limits (1-5/s)
|
||||
- Trade/purchase: Very low limits (0.5-1/s)
|
||||
|
||||
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // Allow 3s worth of burst
|
||||
```
|
||||
|
||||
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **Combine with auth**: Rate limit by user ID for authenticated users:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **Monitor and adjust**: Log rate limit events to tune your limits:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// Combine auth and rate limit
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Use user ID for rate limiting
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// Handle rate limits
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect after 20 consecutive rate limits
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('Room created with auth + rate limit protection')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// High-frequency movement (default rate limit)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// Low-frequency trading (strict limit)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// Process trade...
|
||||
}
|
||||
|
||||
// Chat with moderate limit
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// System messages - no limit
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// Custom rate limit handling
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -311,6 +311,93 @@ client.send('RoomMessage', {
|
||||
})
|
||||
```
|
||||
|
||||
## ECSRoom
|
||||
|
||||
`ECSRoom` is a room base class with ECS World support, suitable for games that need ECS architecture.
|
||||
|
||||
### Server Startup
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { createServer } from '@esengine/server';
|
||||
import { GameRoom } from './rooms/GameRoom.js';
|
||||
|
||||
// Initialize Core
|
||||
Core.create();
|
||||
|
||||
// Global game loop
|
||||
setInterval(() => Core.update(1/60), 16);
|
||||
|
||||
// Create server
|
||||
const server = await createServer({ port: 3000 });
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Define ECSRoom
|
||||
|
||||
```typescript
|
||||
import { ECSRoom, Player } from '@esengine/server/ecs';
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
// Define sync component
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
// Define room
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new MovementSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = player.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom API
|
||||
|
||||
```typescript
|
||||
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
|
||||
protected readonly world: World; // ECS World
|
||||
protected readonly scene: Scene; // Main scene
|
||||
|
||||
// Scene management
|
||||
protected addSystem(system: EntitySystem): void;
|
||||
protected createEntity(name?: string): Entity;
|
||||
protected createPlayerEntity(playerId: string, name?: string): Entity;
|
||||
protected getPlayerEntity(playerId: string): Entity | undefined;
|
||||
protected destroyPlayerEntity(playerId: string): void;
|
||||
|
||||
// State sync
|
||||
protected sendFullState(player: Player): void;
|
||||
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
|
||||
protected broadcastDelta(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### @sync Decorator
|
||||
|
||||
Mark component fields that need network synchronization:
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Set Appropriate Tick Rate**
|
||||
|
||||
@@ -1,8 +1,80 @@
|
||||
---
|
||||
title: "State Sync"
|
||||
description: "Interpolation, prediction and snapshot buffers"
|
||||
description: "Component sync, interpolation, prediction and snapshot buffers"
|
||||
---
|
||||
|
||||
## Component Sync System
|
||||
|
||||
ECS component state synchronization based on `@sync` decorator.
|
||||
|
||||
### Define Sync Component
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
// Fields without @sync won't be synced
|
||||
localData: any;
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side Encoding
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
const syncSystem = new ComponentSyncSystem({}, true);
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Encode all entities (initial connection)
|
||||
const fullData = syncSystem.encodeAllEntities(true);
|
||||
sendToClient(fullData);
|
||||
|
||||
// Encode delta (only send changes)
|
||||
const deltaData = syncSystem.encodeDelta();
|
||||
if (deltaData) {
|
||||
broadcast(deltaData);
|
||||
}
|
||||
```
|
||||
|
||||
### Client-side Decoding
|
||||
|
||||
```typescript
|
||||
const syncSystem = new ComponentSyncSystem();
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// Register component types
|
||||
syncSystem.registerComponent(PlayerComponent);
|
||||
|
||||
// Listen for sync events
|
||||
syncSystem.addSyncListener((event) => {
|
||||
if (event.type === 'entitySpawned') {
|
||||
console.log('New entity:', event.entityId);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply state
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### Sync Types
|
||||
|
||||
| Type | Description | Bytes |
|
||||
|------|-------------|-------|
|
||||
| `"boolean"` | Boolean | 1 |
|
||||
| `"int8"` / `"uint8"` | 8-bit integer | 1 |
|
||||
| `"int16"` / `"uint16"` | 16-bit integer | 2 |
|
||||
| `"int32"` / `"uint32"` | 32-bit integer | 4 |
|
||||
| `"float32"` | 32-bit float | 4 |
|
||||
| `"float64"` | 64-bit float | 8 |
|
||||
| `"string"` | String | Variable |
|
||||
|
||||
## Snapshot Buffer
|
||||
|
||||
Stores server state snapshots for interpolation:
|
||||
|
||||
506
docs/src/content/docs/modules/network/auth.md
Normal file
506
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
title: "认证系统"
|
||||
description: "使用 JWT 和 Session 提供者为游戏服务器添加认证功能"
|
||||
---
|
||||
|
||||
`@esengine/server` 包内置了可插拔的认证系统,支持 JWT、会话认证和自定义提供者。
|
||||
|
||||
## 安装
|
||||
|
||||
认证功能已包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> 注意:`jsonwebtoken` 是可选的 peer dependency,仅在使用 JWT 认证时需要。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### JWT 认证
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// 创建 JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 小时
|
||||
})
|
||||
|
||||
// 用认证包装服务器
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义需要认证的房间
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} 加入了游戏!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 认证提供者
|
||||
|
||||
### JWT 提供者
|
||||
|
||||
使用 JSON Web Tokens 实现无状态认证:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// 必填:密钥
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// 可选:算法(默认:HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// 可选:过期时间(秒,默认:3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// 可选:签发者(用于验证)
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// 可选:受众(用于验证)
|
||||
audience: 'my-game-client',
|
||||
|
||||
// 可选:自定义用户提取
|
||||
getUser: async (payload) => {
|
||||
// 从数据库获取用户
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// 签发令牌(用于登录接口)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// 解码但不验证(用于调试)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Session 提供者
|
||||
|
||||
使用服务端会话实现有状态认证:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// 自定义存储实现
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||
|
||||
// 可选:每次请求时验证用户
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// 创建会话(用于登录接口)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// 撤销会话(用于登出)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## 服务器认证 Mixin
|
||||
|
||||
`withAuth` 函数用于包装服务器添加认证功能:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// 必填:认证提供者
|
||||
provider: jwtProvider,
|
||||
|
||||
// 必填:从请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
// 从查询字符串获取
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// 或从请求头获取
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// 可选:处理认证失败
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`认证失败: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 访问认证上下文
|
||||
|
||||
认证后,可以从连接获取认证上下文:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`用户 ${auth.userId} 已连接`)
|
||||
console.log(`角色: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 房间认证 Mixin
|
||||
|
||||
`withRoomAuth` 函数为房间添加认证检查:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// 要求认证才能加入
|
||||
requireAuth: true,
|
||||
|
||||
// 可选:要求特定角色
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// 可选:角色检查模式('any' 或 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player 拥有 .auth 和 .user 属性
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了`)
|
||||
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// 可选:自定义认证验证
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// 额外的验证逻辑
|
||||
if (player.auth.hasRole('banned')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name ?? '访客',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer 接口
|
||||
|
||||
认证房间中的玩家拥有额外属性:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// 完整认证上下文
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// 用户信息(auth.user 的快捷方式)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### 房间认证辅助方法
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// 通过用户 ID 获取玩家
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// 获取拥有特定角色的所有玩家
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// 获取带认证信息的玩家
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证装饰器
|
||||
|
||||
### @requireAuth
|
||||
|
||||
标记消息处理器需要认证:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// 只有已认证玩家才能交易
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// 访客也可以聊天
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
要求特定角色才能访问消息处理器:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// 只有管理员才能封禁
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// 版主或管理员可以禁言
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// 需要同时拥有 verified 和 premium 角色
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证上下文 API
|
||||
|
||||
认证上下文提供多种检查认证状态的方法:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// 认证状态
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// 角色检查
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`AuthContext` 类(实现类)还提供:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// 从认证结果设置认证状态
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// 清除认证状态
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
使用模拟认证提供者进行单元测试:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// 创建带预设用户的模拟提供者
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // 为未知令牌创建用户
|
||||
})
|
||||
|
||||
// 在测试中使用
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// 使用用户 ID 作为令牌进行验证
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// 动态添加/移除用户
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// 撤销令牌
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// 重置到初始状态
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
认证错误包含错误码用于程序化处理:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||
| 'USER_NOT_FOUND' // 用户查找失败
|
||||
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||
| 'RATE_LIMITED' // 请求过于频繁
|
||||
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||
|
||||
// 在认证失败处理器中
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
以下是使用 JWT 认证的完整示例:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// 类型定义
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建带认证的服务器
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 带认证的游戏房间
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('游戏房间已创建')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// 处理移动
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, '被管理员踢出')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||
|
||||
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||
|
||||
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||
|
||||
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||
|
||||
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||
|
||||
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||
|
||||
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "速率限制"
|
||||
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
|
||||
---
|
||||
|
||||
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
|
||||
|
||||
## 安装
|
||||
|
||||
速率限制包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// 受速率限制保护(默认 10 msg/s)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// 交易使用更严格的限制
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// 心跳不限制
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制策略
|
||||
|
||||
### 令牌桶(默认)
|
||||
|
||||
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // 补充速率
|
||||
burstSize: 20, // 桶容量
|
||||
}) { }
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
```
|
||||
配置: rate=10/s, burstSize=20
|
||||
|
||||
[0s] 桶满: 20 令牌
|
||||
[0s] 收到 15 条消息 → 允许,剩余 5
|
||||
[0.5s] 补充 5 令牌 → 10 令牌
|
||||
[0.5s] 收到 8 条消息 → 允许,剩余 2
|
||||
[0.6s] 补充 1 令牌 → 3 令牌
|
||||
[0.6s] 收到 5 条消息 → 允许 3,拒绝 2
|
||||
```
|
||||
|
||||
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
|
||||
|
||||
### 滑动窗口
|
||||
|
||||
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 需要精确限流且不需要突发容忍的场景。
|
||||
|
||||
### 固定窗口
|
||||
|
||||
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 简单场景,可接受边界突发。
|
||||
|
||||
## 配置
|
||||
|
||||
### 房间配置
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// 每秒允许的消息数(默认: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// 突发容量 / 桶大小(默认: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 被限流时的回调
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 限流时断开连接(默认: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// 连续 N 次限流后断开(0 = 永不)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// 自定义键函数(默认: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// 清理间隔(毫秒,默认: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### 单消息配置
|
||||
|
||||
使用装饰器为特定消息配置速率限制:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// 此消息使用自定义速率限制
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// 此消息消耗 5 个令牌
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// 豁免速率限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// 替代方案:显式指定消息类型
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## 与认证系统组合
|
||||
|
||||
速率限制可与认证系统无缝配合:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// 同时应用两个 mixin
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} 已加入,受速率限制保护`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制结果
|
||||
|
||||
当消息被限流时,回调会收到结果对象:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// 是否允许请求
|
||||
allowed: boolean
|
||||
|
||||
// 剩余配额
|
||||
remaining: number
|
||||
|
||||
// 配额重置时间(时间戳)
|
||||
resetAt: number
|
||||
|
||||
// 重试等待时间(毫秒)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 访问速率限制上下文
|
||||
|
||||
你可以访问任何玩家的速率限制上下文:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// 检查但不消费
|
||||
const status = context?.check()
|
||||
console.log(`剩余: ${status?.remaining}`)
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 或使用独立函数
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## 自定义策略
|
||||
|
||||
你可以直接使用策略进行自定义实现:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// 直接创建策略
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // 每秒令牌数
|
||||
capacity: 20, // 最大令牌数
|
||||
})
|
||||
|
||||
// 检查并消费
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// 处理消息
|
||||
} else {
|
||||
// 被限流,等待 result.retryAfter 毫秒
|
||||
}
|
||||
|
||||
// 检查但不消费
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// 重置某个键
|
||||
strategy.reset('player-123')
|
||||
|
||||
// 清理过期记录
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## 速率限制上下文
|
||||
|
||||
`RateLimitContext` 类管理单个玩家的速率限制:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// 检查但不消费
|
||||
context.check()
|
||||
|
||||
// 消费配额
|
||||
context.consume()
|
||||
|
||||
// 带消耗量消费
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// 为特定消息类型消费
|
||||
context.consume('Trade')
|
||||
|
||||
// 设置单消息策略
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// 重置
|
||||
context.reset()
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## 房间生命周期钩子
|
||||
|
||||
你可以重写 `onRateLimited` 钩子进行自定义处理:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// 记录事件
|
||||
console.log(`玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
|
||||
// 发送自定义错误
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `请慢一点!${result.retryAfter}ms 后重试`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
|
||||
|
||||
2. **设置合适的限制**:考虑你的游戏机制:
|
||||
- 移动消息:较高限制(20-60/s)
|
||||
- 聊天消息:较低限制(1-5/s)
|
||||
- 交易/购买:非常低的限制(0.5-1/s)
|
||||
|
||||
3. **使用突发容量**:允许短暂突发以获得响应式体验:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // 允许 3 秒的突发
|
||||
```
|
||||
|
||||
4. **豁免关键消息**:不要限制心跳或系统消息:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **与认证结合**:对已认证用户按用户 ID 限流:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **监控和调整**:记录限流事件以调整限制:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **优雅降级**:发送信息性错误而不是直接断开:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: '请求过于频繁',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// 组合认证和速率限制
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 使用用户 ID 进行限流
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// 处理限流
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 连续 20 次限流后断开
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('房间已创建,具有认证 + 速率限制保护')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// 高频移动(默认速率限制)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// 低频交易(严格限制)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// 处理交易...
|
||||
}
|
||||
|
||||
// 聊天使用中等限制
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// 系统消息 - 不限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// 自定义限流处理
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -311,6 +311,93 @@ client.send('RoomMessage', {
|
||||
})
|
||||
```
|
||||
|
||||
## ECSRoom
|
||||
|
||||
`ECSRoom` 是带有 ECS World 支持的房间基类,适用于需要 ECS 架构的游戏。
|
||||
|
||||
### 服务端启动
|
||||
|
||||
```typescript
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { createServer } from '@esengine/server';
|
||||
import { GameRoom } from './rooms/GameRoom.js';
|
||||
|
||||
// 初始化 Core
|
||||
Core.create();
|
||||
|
||||
// 全局游戏循环
|
||||
setInterval(() => Core.update(1/60), 16);
|
||||
|
||||
// 创建服务器
|
||||
const server = await createServer({ port: 3000 });
|
||||
server.define('game', GameRoom);
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### 定义 ECSRoom
|
||||
|
||||
```typescript
|
||||
import { ECSRoom, Player } from '@esengine/server/ecs';
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
// 定义同步组件
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new MovementSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = player.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ECSRoom API
|
||||
|
||||
```typescript
|
||||
abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> {
|
||||
protected readonly world: World; // ECS World
|
||||
protected readonly scene: Scene; // 主场景
|
||||
|
||||
// 场景管理
|
||||
protected addSystem(system: EntitySystem): void;
|
||||
protected createEntity(name?: string): Entity;
|
||||
protected createPlayerEntity(playerId: string, name?: string): Entity;
|
||||
protected getPlayerEntity(playerId: string): Entity | undefined;
|
||||
protected destroyPlayerEntity(playerId: string): void;
|
||||
|
||||
// 状态同步
|
||||
protected sendFullState(player: Player): void;
|
||||
protected broadcastSpawn(entity: Entity, prefabType?: string): void;
|
||||
protected broadcastDelta(): void;
|
||||
}
|
||||
```
|
||||
|
||||
### @sync 装饰器
|
||||
|
||||
标记需要网络同步的组件字段:
|
||||
|
||||
| 类型 | 描述 | 字节数 |
|
||||
|------|------|--------|
|
||||
| `"boolean"` | 布尔值 | 1 |
|
||||
| `"int8"` / `"uint8"` | 8位整数 | 1 |
|
||||
| `"int16"` / `"uint16"` | 16位整数 | 2 |
|
||||
| `"int32"` / `"uint32"` | 32位整数 | 4 |
|
||||
| `"float32"` | 32位浮点 | 4 |
|
||||
| `"float64"` | 64位浮点 | 8 |
|
||||
| `"string"` | 字符串 | 变长 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **合理设置 Tick 频率**
|
||||
|
||||
@@ -1,8 +1,80 @@
|
||||
---
|
||||
title: "状态同步"
|
||||
description: "插值、预测和快照缓冲区"
|
||||
description: "组件同步、插值、预测和快照缓冲区"
|
||||
---
|
||||
|
||||
## 组件同步系统
|
||||
|
||||
基于 `@sync` 装饰器的 ECS 组件状态同步。
|
||||
|
||||
### 定义同步组件
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
// 不带 @sync 的字段不会同步
|
||||
localData: any;
|
||||
}
|
||||
```
|
||||
|
||||
### 服务端编码
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
const syncSystem = new ComponentSyncSystem({}, true);
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// 编码所有实体(首次连接)
|
||||
const fullData = syncSystem.encodeAllEntities(true);
|
||||
sendToClient(fullData);
|
||||
|
||||
// 编码增量(只发送变更)
|
||||
const deltaData = syncSystem.encodeDelta();
|
||||
if (deltaData) {
|
||||
broadcast(deltaData);
|
||||
}
|
||||
```
|
||||
|
||||
### 客户端解码
|
||||
|
||||
```typescript
|
||||
const syncSystem = new ComponentSyncSystem();
|
||||
scene.addSystem(syncSystem);
|
||||
|
||||
// 注册组件类型
|
||||
syncSystem.registerComponent(PlayerComponent);
|
||||
|
||||
// 监听同步事件
|
||||
syncSystem.addSyncListener((event) => {
|
||||
if (event.type === 'entitySpawned') {
|
||||
console.log('New entity:', event.entityId);
|
||||
}
|
||||
});
|
||||
|
||||
// 应用状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 同步类型
|
||||
|
||||
| 类型 | 描述 | 字节数 |
|
||||
|------|------|--------|
|
||||
| `"boolean"` | 布尔值 | 1 |
|
||||
| `"int8"` / `"uint8"` | 8位整数 | 1 |
|
||||
| `"int16"` / `"uint16"` | 16位整数 | 2 |
|
||||
| `"int32"` / `"uint32"` | 32位整数 | 4 |
|
||||
| `"float32"` | 32位浮点 | 4 |
|
||||
| `"float64"` | 64位浮点 | 8 |
|
||||
| `"string"` | 字符串 | 变长 |
|
||||
|
||||
## 快照缓冲区
|
||||
|
||||
用于存储服务器状态快照并进行插值:
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @esengine/behavior-tree
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/behavior-tree",
|
||||
"version": "1.0.3",
|
||||
"version": "2.0.1",
|
||||
"description": "ECS-based AI behavior tree system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @esengine/blueprint
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/blueprint",
|
||||
"version": "1.0.2",
|
||||
"version": "2.0.1",
|
||||
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,118 @@
|
||||
# @esengine/ecs-framework
|
||||
|
||||
## 2.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#392](https://github.com/esengine/esengine/pull/392) [`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76) Thanks [@esengine](https://github.com/esengine)! - fix(sync): Decoder 现在使用 GlobalComponentRegistry 查找组件 | Decoder now uses GlobalComponentRegistry for component lookup
|
||||
|
||||
**问题 | Problem:**
|
||||
1. `Decoder.ts` 有自己独立的 `componentRegistry` Map,与 `GlobalComponentRegistry` 完全分离。这导致通过 `@ECSComponent` 装饰器注册的组件在网络反序列化时找不到,产生 "Unknown component type" 错误。
|
||||
2. `@sync` 装饰器使用 `constructor.name` 作为 `typeId`,而不是 `@ECSComponent` 装饰器指定的名称,导致编码和解码使用不同的类型 ID。
|
||||
3. `Decoder.ts` had its own local `componentRegistry` Map that was completely separate from `GlobalComponentRegistry`. This caused components registered via `@ECSComponent` decorator to not be found during network deserialization, resulting in "Unknown component type" errors.
|
||||
4. `@sync` decorator used `constructor.name` as `typeId` instead of the name specified by `@ECSComponent` decorator, causing encoding and decoding to use different type IDs.
|
||||
|
||||
**修改 | Changes:**
|
||||
- 从 Decoder.ts 中移除本地 `componentRegistry`
|
||||
- 更新 `decodeEntity` 和 `decodeSpawn` 使用 `GlobalComponentRegistry.getComponentType()`
|
||||
- 移除已废弃的 `registerSyncComponent` 和 `autoRegisterSyncComponent` 函数
|
||||
- 更新 `@sync` 装饰器使用 `getComponentTypeName()` 获取组件类型名称
|
||||
- 更新 `@ECSComponent` 装饰器同步更新 `SYNC_METADATA.typeId`
|
||||
- Removed local `componentRegistry` from Decoder.ts
|
||||
- Updated `decodeEntity` and `decodeSpawn` to use `GlobalComponentRegistry.getComponentType()`
|
||||
- Removed deprecated `registerSyncComponent` and `autoRegisterSyncComponent` functions
|
||||
- Updated `@sync` decorator to use `getComponentTypeName()` for component type name
|
||||
- Updated `@ECSComponent` decorator to sync update `SYNC_METADATA.typeId`
|
||||
|
||||
现在使用 `@ECSComponent` 装饰器的组件会自动可用于网络同步解码,无需手动注册。
|
||||
|
||||
Now `@ECSComponent` decorated components are automatically available for network sync decoding without any manual registration.
|
||||
|
||||
## 2.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
|
||||
|
||||
## @esengine/ecs-framework
|
||||
|
||||
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = '';
|
||||
@sync('uint16') score: number = 0;
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增导出
|
||||
- `sync` - 标记需要同步的字段装饰器
|
||||
- `SyncType` - 支持的同步类型
|
||||
- `SyncOperation` - 同步操作类型(FULL/DELTA/SPAWN/DESPAWN)
|
||||
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
|
||||
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
|
||||
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
|
||||
- `ChangeTracker` - 字段级变更追踪
|
||||
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
|
||||
|
||||
### 内部方法标记
|
||||
|
||||
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
|
||||
- `Scene.update()`
|
||||
- `SceneManager.update()`
|
||||
- `WorldManager.updateAll()`
|
||||
|
||||
## @esengine/network
|
||||
|
||||
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
// 服务端:编码状态
|
||||
const data = syncSystem.encodeAllEntities(false);
|
||||
|
||||
// 客户端:解码状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 修复
|
||||
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
|
||||
|
||||
## @esengine/server
|
||||
|
||||
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
|
||||
|
||||
```typescript
|
||||
import { ECSRoom } from '@esengine/server/ecs';
|
||||
|
||||
// 服务端启动
|
||||
Core.create();
|
||||
setInterval(() => Core.update(1 / 60), 16);
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new PhysicsSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
entity.addComponent(new PlayerComponent());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计
|
||||
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
|
||||
- `Core.update()` 统一更新 Time 和所有 World
|
||||
- `onTick()` 只处理状态同步逻辑
|
||||
|
||||
## 2.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/ecs-framework",
|
||||
"version": "2.4.4",
|
||||
"version": "2.5.1",
|
||||
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
|
||||
@@ -10,10 +10,16 @@ import { Int32 } from './Core/SoAStorage';
|
||||
* @en Components in ECS architecture should be pure data containers.
|
||||
* All game logic should be implemented in EntitySystem, not inside components.
|
||||
*
|
||||
* @zh **重要:所有 Component 子类都必须使用 @ECSComponent 装饰器!**
|
||||
* @zh 该装饰器用于注册组件类型名称,是序列化、网络同步等功能正常工作的前提。
|
||||
* @en **IMPORTANT: All Component subclasses MUST use the @ECSComponent decorator!**
|
||||
* @en This decorator registers the component type name, which is required for serialization, network sync, etc.
|
||||
*
|
||||
* @example
|
||||
* @zh 推荐做法:纯数据组件
|
||||
* @en Recommended: Pure data component
|
||||
* @zh 正确做法:使用 @ECSComponent 装饰器
|
||||
* @en Correct: Use @ECSComponent decorator
|
||||
* ```typescript
|
||||
* @ECSComponent('HealthComponent')
|
||||
* class HealthComponent extends Component {
|
||||
* public health: number = 100;
|
||||
* public maxHealth: number = 100;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ComponentEditorOptions,
|
||||
type ComponentType
|
||||
} from '../Core/ComponentStorage/ComponentTypeUtils';
|
||||
import { SYNC_METADATA, type SyncMetadata } from '../Sync/types';
|
||||
|
||||
/**
|
||||
* 存储系统类型名称的Symbol键
|
||||
@@ -138,6 +139,14 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
|
||||
metadata[COMPONENT_EDITOR_OPTIONS] = options.editor;
|
||||
}
|
||||
|
||||
// 更新 @sync 装饰器创建的 SYNC_METADATA.typeId(如果存在)
|
||||
// Update SYNC_METADATA.typeId created by @sync decorator (if exists)
|
||||
// Property decorators execute before class decorators, so @sync may have used constructor.name
|
||||
const syncMeta = (target as any)[SYNC_METADATA] as SyncMetadata | undefined;
|
||||
if (syncMeta) {
|
||||
syncMeta.typeId = typeName;
|
||||
}
|
||||
|
||||
// 自动注册到全局 ComponentRegistry,使组件可以通过名称查找
|
||||
// Auto-register to GlobalComponentRegistry, enabling lookup by name
|
||||
GlobalComponentRegistry.register(target);
|
||||
|
||||
@@ -508,7 +508,9 @@ export class Scene implements IScene {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
* @zh 更新场景
|
||||
* @en Update scene
|
||||
* @internal 由 SceneManager 或 World 调用,用户不应直接调用
|
||||
*/
|
||||
public update() {
|
||||
this.epochManager.increment();
|
||||
|
||||
@@ -240,18 +240,9 @@ export class SceneManager implements IService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
*
|
||||
* 应该在每帧的游戏循环中调用。
|
||||
* 会自动处理延迟场景切换。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function gameLoop(deltaTime: number) {
|
||||
* Core.update(deltaTime);
|
||||
* sceneManager.update(); // 每帧调用
|
||||
* }
|
||||
* ```
|
||||
* @zh 更新场景
|
||||
* @en Update scene
|
||||
* @internal 由 Core.update() 调用,用户不应直接调用
|
||||
*/
|
||||
public update(): void {
|
||||
// 处理延迟场景切换
|
||||
|
||||
125
packages/framework/core/src/ECS/Sync/ChangeTracker.ts
Normal file
125
packages/framework/core/src/ECS/Sync/ChangeTracker.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @zh 组件变更追踪器
|
||||
* @en Component change tracker
|
||||
*
|
||||
* @zh 用于追踪 @sync 标记字段的变更,支持增量同步
|
||||
* @en Tracks changes to @sync marked fields for delta synchronization
|
||||
*/
|
||||
export class ChangeTracker {
|
||||
/**
|
||||
* @zh 脏字段索引集合
|
||||
* @en Set of dirty field indices
|
||||
*/
|
||||
private _dirtyFields: Set<number> = new Set();
|
||||
|
||||
/**
|
||||
* @zh 是否有任何变更
|
||||
* @en Whether there are any changes
|
||||
*/
|
||||
private _hasChanges: boolean = false;
|
||||
|
||||
/**
|
||||
* @zh 上次同步的时间戳
|
||||
* @en Last sync timestamp
|
||||
*/
|
||||
private _lastSyncTime: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 标记字段为脏
|
||||
* @en Mark field as dirty
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public setDirty(fieldIndex: number): void {
|
||||
this._dirtyFields.add(fieldIndex);
|
||||
this._hasChanges = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有变更
|
||||
* @en Check if there are any changes
|
||||
*/
|
||||
public hasChanges(): boolean {
|
||||
return this._hasChanges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查特定字段是否脏
|
||||
* @en Check if a specific field is dirty
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public isDirty(fieldIndex: number): boolean {
|
||||
return this._dirtyFields.has(fieldIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有脏字段索引
|
||||
* @en Get all dirty field indices
|
||||
*/
|
||||
public getDirtyFields(): number[] {
|
||||
return Array.from(this._dirtyFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取脏字段数量
|
||||
* @en Get number of dirty fields
|
||||
*/
|
||||
public getDirtyCount(): number {
|
||||
return this._dirtyFields.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有变更标记
|
||||
* @en Clear all change marks
|
||||
*/
|
||||
public clear(): void {
|
||||
this._dirtyFields.clear();
|
||||
this._hasChanges = false;
|
||||
this._lastSyncTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除特定字段的变更标记
|
||||
* @en Clear change mark for a specific field
|
||||
*
|
||||
* @param fieldIndex - @zh 字段索引 @en Field index
|
||||
*/
|
||||
public clearField(fieldIndex: number): void {
|
||||
this._dirtyFields.delete(fieldIndex);
|
||||
if (this._dirtyFields.size === 0) {
|
||||
this._hasChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取上次同步时间
|
||||
* @en Get last sync time
|
||||
*/
|
||||
public get lastSyncTime(): number {
|
||||
return this._lastSyncTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 标记所有字段为脏(用于首次同步)
|
||||
* @en Mark all fields as dirty (for initial sync)
|
||||
*
|
||||
* @param fieldCount - @zh 字段数量 @en Field count
|
||||
*/
|
||||
public markAllDirty(fieldCount: number): void {
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
this._dirtyFields.add(i);
|
||||
}
|
||||
this._hasChanges = fieldCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置追踪器
|
||||
* @en Reset tracker
|
||||
*/
|
||||
public reset(): void {
|
||||
this._dirtyFields.clear();
|
||||
this._hasChanges = false;
|
||||
this._lastSyncTime = 0;
|
||||
}
|
||||
}
|
||||
219
packages/framework/core/src/ECS/Sync/decorators.ts
Normal file
219
packages/framework/core/src/ECS/Sync/decorators.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @zh 网络同步装饰器
|
||||
* @en Network synchronization decorators
|
||||
*
|
||||
* @zh 提供 @sync 装饰器,用于标记需要网络同步的 Component 字段
|
||||
* @en Provides @sync decorator to mark Component fields for network synchronization
|
||||
*/
|
||||
|
||||
import type { SyncType, SyncFieldMetadata, SyncMetadata } from './types';
|
||||
import { SYNC_METADATA, CHANGE_TRACKER } from './types';
|
||||
import { ChangeTracker } from './ChangeTracker';
|
||||
import { getComponentTypeName } from '../Core/ComponentStorage/ComponentTypeUtils';
|
||||
|
||||
/**
|
||||
* @zh 获取或创建组件的同步元数据
|
||||
* @en Get or create sync metadata for a component class
|
||||
*
|
||||
* @param target - @zh 组件类的原型 @en Component class prototype
|
||||
* @returns @zh 同步元数据 @en Sync metadata
|
||||
*/
|
||||
function getOrCreateSyncMetadata(target: any): SyncMetadata {
|
||||
const constructor = target.constructor;
|
||||
|
||||
// Check if has own metadata (not inherited)
|
||||
const hasOwnMetadata = Object.prototype.hasOwnProperty.call(constructor, SYNC_METADATA);
|
||||
|
||||
if (hasOwnMetadata) {
|
||||
return constructor[SYNC_METADATA];
|
||||
}
|
||||
|
||||
// Check for inherited metadata
|
||||
const inheritedMetadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
|
||||
// Create new metadata (copy from inherited if exists)
|
||||
// Use getComponentTypeName to get @ECSComponent decorator name, or fall back to constructor.name
|
||||
const metadata: SyncMetadata = {
|
||||
typeId: getComponentTypeName(constructor),
|
||||
fields: inheritedMetadata ? [...inheritedMetadata.fields] : [],
|
||||
fieldIndexMap: inheritedMetadata ? new Map(inheritedMetadata.fieldIndexMap) : new Map()
|
||||
};
|
||||
|
||||
constructor[SYNC_METADATA] = metadata;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 同步字段装饰器
|
||||
* @en Sync field decorator
|
||||
*
|
||||
* @zh 标记 Component 字段为可网络同步。被标记的字段会自动追踪变更,
|
||||
* 并在值修改时触发变更追踪器。
|
||||
* @en Marks a Component field for network synchronization. Marked fields
|
||||
* automatically track changes and trigger the change tracker on modification.
|
||||
*
|
||||
* @param type - @zh 字段的同步类型 @en Sync type of the field
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Component, ECSComponent } from '@esengine/ecs-framework';
|
||||
* import { sync } from '@esengine/ecs-framework';
|
||||
*
|
||||
* @ECSComponent('Player')
|
||||
* class PlayerComponent extends Component {
|
||||
* @sync("string") name: string = "";
|
||||
* @sync("uint16") score: number = 0;
|
||||
* @sync("float32") x: number = 0;
|
||||
* @sync("float32") y: number = 0;
|
||||
*
|
||||
* // 不带 @sync 的字段不会同步
|
||||
* // Fields without @sync will not be synchronized
|
||||
* localData: any;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function sync(type: SyncType) {
|
||||
return function (target: any, propertyKey: string) {
|
||||
const metadata = getOrCreateSyncMetadata(target);
|
||||
|
||||
// Assign field index (auto-increment based on field count)
|
||||
const fieldIndex = metadata.fields.length;
|
||||
|
||||
// Create field metadata
|
||||
const fieldMeta: SyncFieldMetadata = {
|
||||
index: fieldIndex,
|
||||
name: propertyKey,
|
||||
type: type
|
||||
};
|
||||
|
||||
// Register field
|
||||
metadata.fields.push(fieldMeta);
|
||||
metadata.fieldIndexMap.set(propertyKey, fieldIndex);
|
||||
|
||||
// Store original property key for getter/setter
|
||||
const privateKey = `_sync_${propertyKey}`;
|
||||
|
||||
// Define getter/setter to intercept value changes
|
||||
Object.defineProperty(target, propertyKey, {
|
||||
get() {
|
||||
return this[privateKey];
|
||||
},
|
||||
set(value: any) {
|
||||
const oldValue = this[privateKey];
|
||||
if (oldValue !== value) {
|
||||
this[privateKey] = value;
|
||||
// Trigger change tracker if exists
|
||||
const tracker = this[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker) {
|
||||
tracker.setDirty(fieldIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取组件类的同步元数据
|
||||
* @en Get sync metadata for a component class
|
||||
*
|
||||
* @param componentClass - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 同步元数据,如果不存在则返回 null @en Sync metadata, or null if not exists
|
||||
*/
|
||||
export function getSyncMetadata(componentClass: any): SyncMetadata | null {
|
||||
if (!componentClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const constructor = typeof componentClass === 'function'
|
||||
? componentClass
|
||||
: componentClass.constructor;
|
||||
|
||||
return constructor[SYNC_METADATA] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查组件是否有同步字段
|
||||
* @en Check if a component has sync fields
|
||||
*
|
||||
* @param component - @zh 组件类或组件实例 @en Component class or instance
|
||||
* @returns @zh 如果有同步字段返回 true @en Returns true if has sync fields
|
||||
*/
|
||||
export function hasSyncFields(component: any): boolean {
|
||||
const metadata = getSyncMetadata(component);
|
||||
return metadata !== null && metadata.fields.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取组件实例的变更追踪器
|
||||
* @en Get change tracker of a component instance
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 变更追踪器,如果不存在则返回 null @en Change tracker, or null if not exists
|
||||
*/
|
||||
export function getChangeTracker(component: any): ChangeTracker | null {
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
return component[CHANGE_TRACKER] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 为组件实例初始化变更追踪器
|
||||
* @en Initialize change tracker for a component instance
|
||||
*
|
||||
* @zh 这个函数应该在组件首次添加到实体时调用。
|
||||
* 它会创建变更追踪器并标记所有字段为脏(用于首次同步)。
|
||||
* @en This function should be called when a component is first added to an entity.
|
||||
* It creates the change tracker and marks all fields as dirty (for initial sync).
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 变更追踪器 @en Change tracker
|
||||
*/
|
||||
export function initChangeTracker(component: any): ChangeTracker {
|
||||
const metadata = getSyncMetadata(component);
|
||||
if (!metadata) {
|
||||
throw new Error('Component does not have sync metadata. Use @sync decorator on fields.');
|
||||
}
|
||||
|
||||
let tracker = component[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (!tracker) {
|
||||
tracker = new ChangeTracker();
|
||||
component[CHANGE_TRACKER] = tracker;
|
||||
}
|
||||
|
||||
// Mark all fields as dirty for initial sync
|
||||
tracker.markAllDirty(metadata.fields.length);
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除组件实例的变更标记
|
||||
* @en Clear change marks for a component instance
|
||||
*
|
||||
* @zh 通常在同步完成后调用,清除所有脏标记
|
||||
* @en Usually called after sync is complete, clears all dirty marks
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
*/
|
||||
export function clearChanges(component: any): void {
|
||||
const tracker = getChangeTracker(component);
|
||||
if (tracker) {
|
||||
tracker.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查组件是否有变更
|
||||
* @en Check if a component has changes
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @returns @zh 如果有变更返回 true @en Returns true if has changes
|
||||
*/
|
||||
export function hasChanges(component: any): boolean {
|
||||
const tracker = getChangeTracker(component);
|
||||
return tracker ? tracker.hasChanges() : false;
|
||||
}
|
||||
285
packages/framework/core/src/ECS/Sync/encoding/BinaryReader.ts
Normal file
285
packages/framework/core/src/ECS/Sync/encoding/BinaryReader.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* @zh 二进制读取器
|
||||
* @en Binary Reader
|
||||
*
|
||||
* @zh 提供高效的二进制数据读取功能
|
||||
* @en Provides efficient binary data reading
|
||||
*/
|
||||
|
||||
import { decodeVarint } from './varint';
|
||||
|
||||
/**
|
||||
* @zh 文本解码器(使用浏览器原生 API)
|
||||
* @en Text decoder (using browser native API)
|
||||
*/
|
||||
const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
||||
|
||||
/**
|
||||
* @zh 二进制读取器
|
||||
* @en Binary reader for decoding data
|
||||
*/
|
||||
export class BinaryReader {
|
||||
/**
|
||||
* @zh 数据缓冲区
|
||||
* @en Data buffer
|
||||
*/
|
||||
private _buffer: Uint8Array;
|
||||
|
||||
/**
|
||||
* @zh DataView 用于读取数值
|
||||
* @en DataView for reading numbers
|
||||
*/
|
||||
private _view: DataView;
|
||||
|
||||
/**
|
||||
* @zh 当前读取位置
|
||||
* @en Current read position
|
||||
*/
|
||||
private _offset: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建二进制读取器
|
||||
* @en Create binary reader
|
||||
*
|
||||
* @param buffer - @zh 要读取的数据 @en Data to read
|
||||
*/
|
||||
constructor(buffer: Uint8Array) {
|
||||
this._buffer = buffer;
|
||||
this._view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前读取位置
|
||||
* @en Get current read position
|
||||
*/
|
||||
public get offset(): number {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置读取位置
|
||||
* @en Set read position
|
||||
*/
|
||||
public set offset(value: number) {
|
||||
this._offset = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取剩余可读字节数
|
||||
* @en Get remaining readable bytes
|
||||
*/
|
||||
public get remaining(): number {
|
||||
return this._buffer.length - this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有更多数据可读
|
||||
* @en Check if there's more data to read
|
||||
*/
|
||||
public hasMore(): boolean {
|
||||
return this._offset < this._buffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取单个字节
|
||||
* @en Read single byte
|
||||
*/
|
||||
public readUint8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._buffer[this._offset++]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取有符号字节
|
||||
* @en Read signed byte
|
||||
*/
|
||||
public readInt8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._view.getInt8(this._offset++);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取布尔值
|
||||
* @en Read boolean
|
||||
*/
|
||||
public readBoolean(): boolean {
|
||||
return this.readUint8() !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 16 位无符号整数(小端序)
|
||||
* @en Read 16-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public readUint16(): number {
|
||||
this.checkBounds(2);
|
||||
const value = this._view.getUint16(this._offset, true);
|
||||
this._offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 16 位有符号整数(小端序)
|
||||
* @en Read 16-bit signed integer (little-endian)
|
||||
*/
|
||||
public readInt16(): number {
|
||||
this.checkBounds(2);
|
||||
const value = this._view.getInt16(this._offset, true);
|
||||
this._offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位无符号整数(小端序)
|
||||
* @en Read 32-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public readUint32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getUint32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位有符号整数(小端序)
|
||||
* @en Read 32-bit signed integer (little-endian)
|
||||
*/
|
||||
public readInt32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getInt32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 32 位浮点数(小端序)
|
||||
* @en Read 32-bit float (little-endian)
|
||||
*/
|
||||
public readFloat32(): number {
|
||||
this.checkBounds(4);
|
||||
const value = this._view.getFloat32(this._offset, true);
|
||||
this._offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取 64 位浮点数(小端序)
|
||||
* @en Read 64-bit float (little-endian)
|
||||
*/
|
||||
public readFloat64(): number {
|
||||
this.checkBounds(8);
|
||||
const value = this._view.getFloat64(this._offset, true);
|
||||
this._offset += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取变长整数
|
||||
* @en Read variable-length integer
|
||||
*/
|
||||
public readVarint(): number {
|
||||
const [value, newOffset] = decodeVarint(this._buffer, this._offset);
|
||||
this._offset = newOffset;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取字符串(UTF-8 编码,带长度前缀)
|
||||
* @en Read string (UTF-8 encoded with length prefix)
|
||||
*/
|
||||
public readString(): string {
|
||||
const length = this.readVarint();
|
||||
this.checkBounds(length);
|
||||
|
||||
const bytes = this._buffer.subarray(this._offset, this._offset + length);
|
||||
this._offset += length;
|
||||
|
||||
if (textDecoder) {
|
||||
return textDecoder.decode(bytes);
|
||||
} else {
|
||||
return this.utf8BytesToString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 读取原始字节
|
||||
* @en Read raw bytes
|
||||
*
|
||||
* @param length - @zh 要读取的字节数 @en Number of bytes to read
|
||||
*/
|
||||
public readBytes(length: number): Uint8Array {
|
||||
this.checkBounds(length);
|
||||
const bytes = this._buffer.slice(this._offset, this._offset + length);
|
||||
this._offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 查看下一个字节但不移动读取位置
|
||||
* @en Peek next byte without advancing read position
|
||||
*/
|
||||
public peekUint8(): number {
|
||||
this.checkBounds(1);
|
||||
return this._buffer[this._offset]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 跳过指定字节数
|
||||
* @en Skip specified number of bytes
|
||||
*/
|
||||
public skip(count: number): void {
|
||||
this.checkBounds(count);
|
||||
this._offset += count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查边界
|
||||
* @en Check bounds
|
||||
*/
|
||||
private checkBounds(size: number): void {
|
||||
if (this._offset + size > this._buffer.length) {
|
||||
throw new Error(`BinaryReader: buffer overflow (offset=${this._offset}, size=${size}, bufferLength=${this._buffer.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh UTF-8 字节转字符串(后备方案)
|
||||
* @en UTF-8 bytes to string (fallback)
|
||||
*/
|
||||
private utf8BytesToString(bytes: Uint8Array): string {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < bytes.length) {
|
||||
let charCode: number;
|
||||
const byte1 = bytes[i++]!;
|
||||
|
||||
if (byte1 < 0x80) {
|
||||
charCode = byte1;
|
||||
} else if (byte1 < 0xE0) {
|
||||
const byte2 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x1F) << 6) | (byte2 & 0x3F);
|
||||
} else if (byte1 < 0xF0) {
|
||||
const byte2 = bytes[i++]!;
|
||||
const byte3 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F);
|
||||
} else {
|
||||
const byte2 = bytes[i++]!;
|
||||
const byte3 = bytes[i++]!;
|
||||
const byte4 = bytes[i++]!;
|
||||
charCode = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) |
|
||||
((byte3 & 0x3F) << 6) | (byte4 & 0x3F);
|
||||
|
||||
// Convert to surrogate pair
|
||||
if (charCode > 0xFFFF) {
|
||||
charCode -= 0x10000;
|
||||
result += String.fromCharCode(0xD800 + (charCode >> 10));
|
||||
charCode = 0xDC00 + (charCode & 0x3FF);
|
||||
}
|
||||
}
|
||||
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
257
packages/framework/core/src/ECS/Sync/encoding/BinaryWriter.ts
Normal file
257
packages/framework/core/src/ECS/Sync/encoding/BinaryWriter.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @zh 二进制写入器
|
||||
* @en Binary Writer
|
||||
*
|
||||
* @zh 提供高效的二进制数据写入功能,支持自动扩容
|
||||
* @en Provides efficient binary data writing with auto-expansion
|
||||
*/
|
||||
|
||||
import { encodeVarint, varintSize } from './varint';
|
||||
|
||||
/**
|
||||
* @zh 文本编码器(使用浏览器原生 API)
|
||||
* @en Text encoder (using browser native API)
|
||||
*/
|
||||
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
|
||||
/**
|
||||
* @zh 二进制写入器
|
||||
* @en Binary writer for encoding data
|
||||
*/
|
||||
export class BinaryWriter {
|
||||
/**
|
||||
* @zh 内部缓冲区
|
||||
* @en Internal buffer
|
||||
*/
|
||||
private _buffer: Uint8Array;
|
||||
|
||||
/**
|
||||
* @zh DataView 用于写入数值
|
||||
* @en DataView for writing numbers
|
||||
*/
|
||||
private _view: DataView;
|
||||
|
||||
/**
|
||||
* @zh 当前写入位置
|
||||
* @en Current write position
|
||||
*/
|
||||
private _offset: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建二进制写入器
|
||||
* @en Create binary writer
|
||||
*
|
||||
* @param initialCapacity - @zh 初始容量 @en Initial capacity
|
||||
*/
|
||||
constructor(initialCapacity: number = 256) {
|
||||
this._buffer = new Uint8Array(initialCapacity);
|
||||
this._view = new DataView(this._buffer.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前写入位置
|
||||
* @en Get current write position
|
||||
*/
|
||||
public get offset(): number {
|
||||
return this._offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取写入的数据
|
||||
* @en Get written data
|
||||
*
|
||||
* @returns @zh 包含写入数据的 Uint8Array @en Uint8Array containing written data
|
||||
*/
|
||||
public toUint8Array(): Uint8Array {
|
||||
return this._buffer.slice(0, this._offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置写入器(清空数据但保留缓冲区)
|
||||
* @en Reset writer (clear data but keep buffer)
|
||||
*/
|
||||
public reset(): void {
|
||||
this._offset = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 确保有足够空间
|
||||
* @en Ensure enough space
|
||||
*
|
||||
* @param size - @zh 需要的额外字节数 @en Extra bytes needed
|
||||
*/
|
||||
private ensureCapacity(size: number): void {
|
||||
const required = this._offset + size;
|
||||
if (required > this._buffer.length) {
|
||||
// Double the buffer size or use required size, whichever is larger
|
||||
const newSize = Math.max(this._buffer.length * 2, required);
|
||||
const newBuffer = new Uint8Array(newSize);
|
||||
newBuffer.set(this._buffer);
|
||||
this._buffer = newBuffer;
|
||||
this._view = new DataView(this._buffer.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入单个字节
|
||||
* @en Write single byte
|
||||
*/
|
||||
public writeUint8(value: number): void {
|
||||
this.ensureCapacity(1);
|
||||
this._buffer[this._offset++] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入有符号字节
|
||||
* @en Write signed byte
|
||||
*/
|
||||
public writeInt8(value: number): void {
|
||||
this.ensureCapacity(1);
|
||||
this._view.setInt8(this._offset++, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入布尔值
|
||||
* @en Write boolean
|
||||
*/
|
||||
public writeBoolean(value: boolean): void {
|
||||
this.writeUint8(value ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 16 位无符号整数(小端序)
|
||||
* @en Write 16-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public writeUint16(value: number): void {
|
||||
this.ensureCapacity(2);
|
||||
this._view.setUint16(this._offset, value, true);
|
||||
this._offset += 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 16 位有符号整数(小端序)
|
||||
* @en Write 16-bit signed integer (little-endian)
|
||||
*/
|
||||
public writeInt16(value: number): void {
|
||||
this.ensureCapacity(2);
|
||||
this._view.setInt16(this._offset, value, true);
|
||||
this._offset += 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位无符号整数(小端序)
|
||||
* @en Write 32-bit unsigned integer (little-endian)
|
||||
*/
|
||||
public writeUint32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setUint32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位有符号整数(小端序)
|
||||
* @en Write 32-bit signed integer (little-endian)
|
||||
*/
|
||||
public writeInt32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setInt32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 32 位浮点数(小端序)
|
||||
* @en Write 32-bit float (little-endian)
|
||||
*/
|
||||
public writeFloat32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this._view.setFloat32(this._offset, value, true);
|
||||
this._offset += 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入 64 位浮点数(小端序)
|
||||
* @en Write 64-bit float (little-endian)
|
||||
*/
|
||||
public writeFloat64(value: number): void {
|
||||
this.ensureCapacity(8);
|
||||
this._view.setFloat64(this._offset, value, true);
|
||||
this._offset += 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入变长整数
|
||||
* @en Write variable-length integer
|
||||
*/
|
||||
public writeVarint(value: number): void {
|
||||
this.ensureCapacity(varintSize(value));
|
||||
this._offset = encodeVarint(value, this._buffer, this._offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入字符串(UTF-8 编码,带长度前缀)
|
||||
* @en Write string (UTF-8 encoded with length prefix)
|
||||
*/
|
||||
public writeString(value: string): void {
|
||||
if (textEncoder) {
|
||||
const encoded = textEncoder.encode(value);
|
||||
this.writeVarint(encoded.length);
|
||||
this.ensureCapacity(encoded.length);
|
||||
this._buffer.set(encoded, this._offset);
|
||||
this._offset += encoded.length;
|
||||
} else {
|
||||
// Fallback for environments without TextEncoder
|
||||
const bytes = this.stringToUtf8Bytes(value);
|
||||
this.writeVarint(bytes.length);
|
||||
this.ensureCapacity(bytes.length);
|
||||
this._buffer.set(bytes, this._offset);
|
||||
this._offset += bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 写入原始字节
|
||||
* @en Write raw bytes
|
||||
*/
|
||||
public writeBytes(data: Uint8Array): void {
|
||||
this.ensureCapacity(data.length);
|
||||
this._buffer.set(data, this._offset);
|
||||
this._offset += data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 字符串转 UTF-8 字节(后备方案)
|
||||
* @en String to UTF-8 bytes (fallback)
|
||||
*/
|
||||
private stringToUtf8Bytes(str: string): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let charCode = str.charCodeAt(i);
|
||||
|
||||
// Handle surrogate pairs
|
||||
if (charCode >= 0xD800 && charCode <= 0xDBFF && i + 1 < str.length) {
|
||||
const next = str.charCodeAt(i + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
charCode = 0x10000 + ((charCode - 0xD800) << 10) + (next - 0xDC00);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (charCode < 0x80) {
|
||||
bytes.push(charCode);
|
||||
} else if (charCode < 0x800) {
|
||||
bytes.push(0xC0 | (charCode >> 6));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
} else if (charCode < 0x10000) {
|
||||
bytes.push(0xE0 | (charCode >> 12));
|
||||
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
} else {
|
||||
bytes.push(0xF0 | (charCode >> 18));
|
||||
bytes.push(0x80 | ((charCode >> 12) & 0x3F));
|
||||
bytes.push(0x80 | ((charCode >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (charCode & 0x3F));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
}
|
||||
372
packages/framework/core/src/ECS/Sync/encoding/Decoder.ts
Normal file
372
packages/framework/core/src/ECS/Sync/encoding/Decoder.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* @zh 组件状态解码器
|
||||
* @en Component state decoder
|
||||
*
|
||||
* @zh 从二进制格式解码并应用到 ECS Component
|
||||
* @en Decodes binary format and applies to ECS Components
|
||||
*/
|
||||
|
||||
import type { Entity } from '../../Entity';
|
||||
import type { Component } from '../../Component';
|
||||
import type { Scene } from '../../Scene';
|
||||
import type { SyncType, SyncMetadata } from '../types';
|
||||
import { SyncOperation, SYNC_METADATA } from '../types';
|
||||
import { BinaryReader } from './BinaryReader';
|
||||
import { GlobalComponentRegistry } from '../../Core/ComponentStorage/ComponentRegistry';
|
||||
|
||||
/**
|
||||
* @zh 解码字段值
|
||||
* @en Decode field value
|
||||
*/
|
||||
function decodeFieldValue(reader: BinaryReader, type: SyncType): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return reader.readBoolean();
|
||||
case 'int8':
|
||||
return reader.readInt8();
|
||||
case 'uint8':
|
||||
return reader.readUint8();
|
||||
case 'int16':
|
||||
return reader.readInt16();
|
||||
case 'uint16':
|
||||
return reader.readUint16();
|
||||
case 'int32':
|
||||
return reader.readInt32();
|
||||
case 'uint32':
|
||||
return reader.readUint32();
|
||||
case 'float32':
|
||||
return reader.readFloat32();
|
||||
case 'float64':
|
||||
return reader.readFloat64();
|
||||
case 'string':
|
||||
return reader.readString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码并应用组件数据
|
||||
* @en Decode and apply component data
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param reader - @zh 二进制读取器 @en Binary reader
|
||||
*/
|
||||
export function decodeComponent(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
reader: BinaryReader
|
||||
): void {
|
||||
const fieldCount = reader.readVarint();
|
||||
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
const fieldIndex = reader.readUint8();
|
||||
const field = metadata.fields[fieldIndex];
|
||||
|
||||
if (field) {
|
||||
const value = decodeFieldValue(reader, field.type);
|
||||
// Directly set the private backing field to avoid triggering change tracking
|
||||
(component as any)[`_sync_${field.name}`] = value;
|
||||
} else {
|
||||
// Unknown field, skip based on type info in metadata
|
||||
console.warn(`Unknown sync field index: ${fieldIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体快照结果
|
||||
* @en Decode entity snapshot result
|
||||
*/
|
||||
export interface DecodeEntityResult {
|
||||
/**
|
||||
* @zh 实体 ID
|
||||
* @en Entity ID
|
||||
*/
|
||||
entityId: number;
|
||||
|
||||
/**
|
||||
* @zh 是否为新实体
|
||||
* @en Whether it's a new entity
|
||||
*/
|
||||
isNew: boolean;
|
||||
|
||||
/**
|
||||
* @zh 解码的组件类型列表
|
||||
* @en List of decoded component types
|
||||
*/
|
||||
componentTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码并应用实体数据
|
||||
* @en Decode and apply entity data
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param reader - @zh 二进制读取器 @en Binary reader
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
export function decodeEntity(
|
||||
scene: Scene,
|
||||
reader: BinaryReader,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeEntityResult {
|
||||
const entityId = reader.readUint32();
|
||||
const componentCount = reader.readVarint();
|
||||
const componentTypes: string[] = [];
|
||||
|
||||
// Find or create entity
|
||||
let entity: Entity | null | undefined = entityMap?.get(entityId);
|
||||
let isNew = false;
|
||||
|
||||
if (!entity) {
|
||||
entity = scene.findEntityById(entityId);
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
// Entity doesn't exist, create it
|
||||
entity = scene.createEntity(`entity_${entityId}`);
|
||||
isNew = true;
|
||||
entityMap?.set(entityId, entity);
|
||||
}
|
||||
|
||||
for (let i = 0; i < componentCount; i++) {
|
||||
const typeId = reader.readString();
|
||||
componentTypes.push(typeId);
|
||||
|
||||
// Find component class from GlobalComponentRegistry
|
||||
const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
|
||||
if (!componentClass) {
|
||||
console.warn(`Unknown component type: ${typeId}`);
|
||||
// Skip component data - we need to read it to advance the reader
|
||||
const fieldCount = reader.readVarint();
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
reader.readUint8(); // fieldIndex
|
||||
// We can't skip properly without knowing the type, so this is a problem
|
||||
// For now, log error and break
|
||||
console.error(`Cannot skip unknown component type: ${typeId}`);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
|
||||
if (!metadata) {
|
||||
console.warn(`Component ${typeId} has no sync metadata`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find or add component
|
||||
let component = entity.getComponent(componentClass);
|
||||
if (!component) {
|
||||
component = entity.addComponent(new componentClass());
|
||||
}
|
||||
|
||||
// Decode component data
|
||||
decodeComponent(component, metadata, reader);
|
||||
}
|
||||
|
||||
return { entityId, isNew, componentTypes };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码快照结果
|
||||
* @en Decode snapshot result
|
||||
*/
|
||||
export interface DecodeSnapshotResult {
|
||||
/**
|
||||
* @zh 操作类型
|
||||
* @en Operation type
|
||||
*/
|
||||
operation: SyncOperation;
|
||||
|
||||
/**
|
||||
* @zh 解码的实体列表
|
||||
* @en List of decoded entities
|
||||
*/
|
||||
entities: DecodeEntityResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码状态快照
|
||||
* @en Decode state snapshot
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
export function decodeSnapshot(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeSnapshotResult {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8() as SyncOperation;
|
||||
const entityCount = reader.readVarint();
|
||||
const entities: DecodeEntityResult[] = [];
|
||||
|
||||
const map = entityMap || new Map<number, Entity>();
|
||||
|
||||
for (let i = 0; i < entityCount; i++) {
|
||||
const result = decodeEntity(scene, reader, map);
|
||||
entities.push(result);
|
||||
}
|
||||
|
||||
return { operation, entities };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码生成消息结果
|
||||
* @en Decode spawn message result
|
||||
*/
|
||||
export interface DecodeSpawnResult {
|
||||
/**
|
||||
* @zh 实体
|
||||
* @en Entity
|
||||
*/
|
||||
entity: Entity;
|
||||
|
||||
/**
|
||||
* @zh 预制体类型
|
||||
* @en Prefab type
|
||||
*/
|
||||
prefabType: string;
|
||||
|
||||
/**
|
||||
* @zh 解码的组件类型列表
|
||||
* @en List of decoded component types
|
||||
*/
|
||||
componentTypes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体生成消息
|
||||
* @en Decode entity spawn message
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 解码结果,如果不是 SPAWN 消息则返回 null @en Decode result, or null if not a SPAWN message
|
||||
*/
|
||||
export function decodeSpawn(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): DecodeSpawnResult | null {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8();
|
||||
|
||||
if (operation !== SyncOperation.SPAWN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityId = reader.readUint32();
|
||||
const prefabType = reader.readString();
|
||||
const componentCount = reader.readVarint();
|
||||
const componentTypes: string[] = [];
|
||||
|
||||
// Create entity
|
||||
const entity = scene.createEntity(`entity_${entityId}`);
|
||||
entityMap?.set(entityId, entity);
|
||||
|
||||
for (let i = 0; i < componentCount; i++) {
|
||||
const typeId = reader.readString();
|
||||
componentTypes.push(typeId);
|
||||
|
||||
const componentClass = GlobalComponentRegistry.getComponentType(typeId) as (new () => Component) | null;
|
||||
if (!componentClass) {
|
||||
console.warn(`Unknown component type: ${typeId}`);
|
||||
// Try to skip
|
||||
const fieldCount = reader.readVarint();
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
reader.readUint8();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata: SyncMetadata | undefined = (componentClass as any)[SYNC_METADATA];
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = entity.addComponent(new (componentClass as new () => Component)());
|
||||
decodeComponent(component, metadata, reader);
|
||||
}
|
||||
|
||||
return { entity, prefabType, componentTypes };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码销毁消息结果
|
||||
* @en Decode despawn message result
|
||||
*/
|
||||
export interface DecodeDespawnResult {
|
||||
/**
|
||||
* @zh 销毁的实体 ID 列表
|
||||
* @en List of despawned entity IDs
|
||||
*/
|
||||
entityIds: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码实体销毁消息
|
||||
* @en Decode entity despawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果,如果不是 DESPAWN 消息则返回 null @en Decode result, or null if not a DESPAWN message
|
||||
*/
|
||||
export function decodeDespawn(data: Uint8Array): DecodeDespawnResult | null {
|
||||
const reader = new BinaryReader(data);
|
||||
const operation = reader.readUint8();
|
||||
|
||||
if (operation !== SyncOperation.DESPAWN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityIds: number[] = [];
|
||||
|
||||
// Check if it's a single entity or batch
|
||||
if (reader.remaining === 4) {
|
||||
// Single entity
|
||||
entityIds.push(reader.readUint32());
|
||||
} else {
|
||||
// Batch
|
||||
const count = reader.readVarint();
|
||||
for (let i = 0; i < count; i++) {
|
||||
entityIds.push(reader.readUint32());
|
||||
}
|
||||
}
|
||||
|
||||
return { entityIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理销毁消息(从场景中移除实体)
|
||||
* @en Process despawn message (remove entities from scene)
|
||||
*
|
||||
* @param scene - @zh 场景 @en Scene
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @param entityMap - @zh 实体 ID 映射(可选)@en Entity ID mapping (optional)
|
||||
* @returns @zh 移除的实体 ID 列表 @en List of removed entity IDs
|
||||
*/
|
||||
export function processDespawn(
|
||||
scene: Scene,
|
||||
data: Uint8Array,
|
||||
entityMap?: Map<number, Entity>
|
||||
): number[] {
|
||||
const result = decodeDespawn(data);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const entityId of result.entityIds) {
|
||||
const entity = entityMap?.get(entityId) || scene.findEntityById(entityId);
|
||||
if (entity) {
|
||||
entity.destroy();
|
||||
entityMap?.delete(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
return result.entityIds;
|
||||
}
|
||||
291
packages/framework/core/src/ECS/Sync/encoding/Encoder.ts
Normal file
291
packages/framework/core/src/ECS/Sync/encoding/Encoder.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @zh 组件状态编码器
|
||||
* @en Component state encoder
|
||||
*
|
||||
* @zh 将 ECS Component 的 @sync 字段编码为二进制格式
|
||||
* @en Encodes @sync fields of ECS Components to binary format
|
||||
*/
|
||||
|
||||
import type { Entity } from '../../Entity';
|
||||
import type { Component } from '../../Component';
|
||||
import type { SyncType, SyncMetadata } from '../types';
|
||||
import { SyncOperation, SYNC_METADATA, CHANGE_TRACKER } from '../types';
|
||||
import type { ChangeTracker } from '../ChangeTracker';
|
||||
import { BinaryWriter } from './BinaryWriter';
|
||||
|
||||
/**
|
||||
* @zh 编码单个字段值
|
||||
* @en Encode a single field value
|
||||
*/
|
||||
function encodeFieldValue(writer: BinaryWriter, value: any, type: SyncType): void {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
writer.writeBoolean(value);
|
||||
break;
|
||||
case 'int8':
|
||||
writer.writeInt8(value);
|
||||
break;
|
||||
case 'uint8':
|
||||
writer.writeUint8(value);
|
||||
break;
|
||||
case 'int16':
|
||||
writer.writeInt16(value);
|
||||
break;
|
||||
case 'uint16':
|
||||
writer.writeUint16(value);
|
||||
break;
|
||||
case 'int32':
|
||||
writer.writeInt32(value);
|
||||
break;
|
||||
case 'uint32':
|
||||
writer.writeUint32(value);
|
||||
break;
|
||||
case 'float32':
|
||||
writer.writeFloat32(value);
|
||||
break;
|
||||
case 'float64':
|
||||
writer.writeFloat64(value);
|
||||
break;
|
||||
case 'string':
|
||||
writer.writeString(value ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码组件的完整状态
|
||||
* @en Encode full state of a component
|
||||
*
|
||||
* @zh 格式: [fieldCount: varint] ([fieldIndex: uint8] [value])...
|
||||
* @en Format: [fieldCount: varint] ([fieldIndex: uint8] [value])...
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
*/
|
||||
export function encodeComponentFull(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
writer: BinaryWriter
|
||||
): void {
|
||||
const fields = metadata.fields;
|
||||
writer.writeVarint(fields.length);
|
||||
|
||||
for (const field of fields) {
|
||||
writer.writeUint8(field.index);
|
||||
const value = (component as any)[field.name];
|
||||
encodeFieldValue(writer, value, field.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码组件的增量状态(只编码脏字段)
|
||||
* @en Encode delta state of a component (only dirty fields)
|
||||
*
|
||||
* @zh 格式: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
|
||||
* @en Format: [dirtyCount: varint] ([fieldIndex: uint8] [value])...
|
||||
*
|
||||
* @param component - @zh 组件实例 @en Component instance
|
||||
* @param metadata - @zh 组件同步元数据 @en Component sync metadata
|
||||
* @param tracker - @zh 变更追踪器 @en Change tracker
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
* @returns @zh 是否有数据编码 @en Whether any data was encoded
|
||||
*/
|
||||
export function encodeComponentDelta(
|
||||
component: Component,
|
||||
metadata: SyncMetadata,
|
||||
tracker: ChangeTracker,
|
||||
writer: BinaryWriter
|
||||
): boolean {
|
||||
if (!tracker.hasChanges()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dirtyFields = tracker.getDirtyFields();
|
||||
writer.writeVarint(dirtyFields.length);
|
||||
|
||||
for (const fieldIndex of dirtyFields) {
|
||||
const field = metadata.fields[fieldIndex];
|
||||
if (field) {
|
||||
writer.writeUint8(field.index);
|
||||
const value = (component as any)[field.name];
|
||||
encodeFieldValue(writer, value, field.type);
|
||||
}
|
||||
}
|
||||
|
||||
return dirtyFields.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体的所有同步组件
|
||||
* @en Encode all sync components of an entity
|
||||
*
|
||||
* @zh 格式:
|
||||
* [entityId: uint32]
|
||||
* [componentCount: varint]
|
||||
* ([typeIdLength: varint] [typeId: string] [componentData])...
|
||||
*
|
||||
* @en Format:
|
||||
* [entityId: uint32]
|
||||
* [componentCount: varint]
|
||||
* ([typeIdLength: varint] [typeId: string] [componentData])...
|
||||
*
|
||||
* @param entity - @zh 实体 @en Entity
|
||||
* @param writer - @zh 二进制写入器 @en Binary writer
|
||||
* @param deltaOnly - @zh 只编码增量 @en Only encode delta
|
||||
* @returns @zh 编码的组件数量 @en Number of components encoded
|
||||
*/
|
||||
export function encodeEntity(
|
||||
entity: Entity,
|
||||
writer: BinaryWriter,
|
||||
deltaOnly: boolean = false
|
||||
): number {
|
||||
writer.writeUint32(entity.id);
|
||||
|
||||
const components = entity.components;
|
||||
const syncComponents: Array<{
|
||||
component: Component;
|
||||
metadata: SyncMetadata;
|
||||
tracker: ChangeTracker | undefined;
|
||||
}> = [];
|
||||
|
||||
// Collect components with sync metadata
|
||||
for (const component of components) {
|
||||
const constructor = component.constructor as any;
|
||||
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
|
||||
// For delta encoding, only include components with changes
|
||||
if (deltaOnly && tracker && !tracker.hasChanges()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
syncComponents.push({ component, metadata, tracker });
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeVarint(syncComponents.length);
|
||||
|
||||
for (const { component, metadata, tracker } of syncComponents) {
|
||||
// Write component type ID
|
||||
writer.writeString(metadata.typeId);
|
||||
|
||||
if (deltaOnly && tracker) {
|
||||
encodeComponentDelta(component, metadata, tracker, writer);
|
||||
} else {
|
||||
encodeComponentFull(component, metadata, writer);
|
||||
}
|
||||
}
|
||||
|
||||
return syncComponents.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码状态快照(多个实体)
|
||||
* @en Encode state snapshot (multiple entities)
|
||||
*
|
||||
* @zh 格式:
|
||||
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
|
||||
* [entityCount: varint]
|
||||
* (entityData)...
|
||||
*
|
||||
* @en Format:
|
||||
* [operation: uint8] (FULL=0, DELTA=1, SPAWN=2, DESPAWN=3)
|
||||
* [entityCount: varint]
|
||||
* (entityData)...
|
||||
*
|
||||
* @param entities - @zh 要编码的实体数组 @en Entities to encode
|
||||
* @param operation - @zh 同步操作类型 @en Sync operation type
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeSnapshot(
|
||||
entities: Entity[],
|
||||
operation: SyncOperation = SyncOperation.FULL
|
||||
): Uint8Array {
|
||||
const writer = new BinaryWriter(1024);
|
||||
|
||||
writer.writeUint8(operation);
|
||||
writer.writeVarint(entities.length);
|
||||
|
||||
const deltaOnly = operation === SyncOperation.DELTA;
|
||||
|
||||
for (const entity of entities) {
|
||||
encodeEntity(entity, writer, deltaOnly);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体生成消息
|
||||
* @en Encode entity spawn message
|
||||
*
|
||||
* @param entity - @zh 生成的实体 @en Spawned entity
|
||||
* @param prefabType - @zh 预制体类型(可选)@en Prefab type (optional)
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
|
||||
const writer = new BinaryWriter(256);
|
||||
|
||||
writer.writeUint8(SyncOperation.SPAWN);
|
||||
writer.writeUint32(entity.id);
|
||||
writer.writeString(prefabType || '');
|
||||
|
||||
// Encode all sync components for initial state
|
||||
const components = entity.components;
|
||||
const syncComponents: Array<{ component: Component; metadata: SyncMetadata }> = [];
|
||||
|
||||
for (const component of components) {
|
||||
const constructor = component.constructor as any;
|
||||
const metadata: SyncMetadata | undefined = constructor[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
syncComponents.push({ component, metadata });
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeVarint(syncComponents.length);
|
||||
|
||||
for (const { component, metadata } of syncComponents) {
|
||||
writer.writeString(metadata.typeId);
|
||||
encodeComponentFull(component, metadata, writer);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体销毁消息
|
||||
* @en Encode entity despawn message
|
||||
*
|
||||
* @param entityId - @zh 销毁的实体 ID @en Despawned entity ID
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeDespawn(entityId: number): Uint8Array {
|
||||
const writer = new BinaryWriter(8);
|
||||
|
||||
writer.writeUint8(SyncOperation.DESPAWN);
|
||||
writer.writeUint32(entityId);
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码批量实体销毁消息
|
||||
* @en Encode batch entity despawn message
|
||||
*
|
||||
* @param entityIds - @zh 销毁的实体 ID 数组 @en Despawned entity IDs
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
export function encodeDespawnBatch(entityIds: number[]): Uint8Array {
|
||||
const writer = new BinaryWriter(8 + entityIds.length * 4);
|
||||
|
||||
writer.writeUint8(SyncOperation.DESPAWN);
|
||||
writer.writeVarint(entityIds.length);
|
||||
|
||||
for (const id of entityIds) {
|
||||
writer.writeUint32(id);
|
||||
}
|
||||
|
||||
return writer.toUint8Array();
|
||||
}
|
||||
50
packages/framework/core/src/ECS/Sync/encoding/index.ts
Normal file
50
packages/framework/core/src/ECS/Sync/encoding/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @zh 二进制编解码模块
|
||||
* @en Binary encoding/decoding module
|
||||
*
|
||||
* @zh 提供 ECS Component 状态的二进制序列化和反序列化功能
|
||||
* @en Provides binary serialization and deserialization for ECS Component state
|
||||
*/
|
||||
|
||||
// Variable-length integer encoding
|
||||
export {
|
||||
varintSize,
|
||||
encodeVarint,
|
||||
decodeVarint,
|
||||
zigzagEncode,
|
||||
zigzagDecode,
|
||||
encodeSignedVarint,
|
||||
decodeSignedVarint
|
||||
} from './varint';
|
||||
|
||||
// Binary writer/reader
|
||||
export { BinaryWriter } from './BinaryWriter';
|
||||
export { BinaryReader } from './BinaryReader';
|
||||
|
||||
// Encoder
|
||||
export {
|
||||
encodeComponentFull,
|
||||
encodeComponentDelta,
|
||||
encodeEntity,
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
encodeDespawnBatch
|
||||
} from './Encoder';
|
||||
|
||||
// Decoder
|
||||
export {
|
||||
decodeComponent,
|
||||
decodeEntity,
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
decodeDespawn,
|
||||
processDespawn
|
||||
} from './Decoder';
|
||||
|
||||
export type {
|
||||
DecodeEntityResult,
|
||||
DecodeSnapshotResult,
|
||||
DecodeSpawnResult,
|
||||
DecodeDespawnResult
|
||||
} from './Decoder';
|
||||
137
packages/framework/core/src/ECS/Sync/encoding/varint.ts
Normal file
137
packages/framework/core/src/ECS/Sync/encoding/varint.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @zh 变长整数编解码
|
||||
* @en Variable-length integer encoding/decoding
|
||||
*
|
||||
* @zh 使用 LEB128 编码方式,可变长度编码正整数。
|
||||
* 小数值使用更少字节,大数值使用更多字节。
|
||||
* @en Uses LEB128 encoding for variable-length integer encoding.
|
||||
* Small values use fewer bytes, large values use more bytes.
|
||||
*
|
||||
* | 值范围 | 字节数 |
|
||||
* |--------|--------|
|
||||
* | 0-127 | 1 |
|
||||
* | 128-16383 | 2 |
|
||||
* | 16384-2097151 | 3 |
|
||||
* | 2097152-268435455 | 4 |
|
||||
* | 268435456+ | 5 |
|
||||
*/
|
||||
|
||||
/**
|
||||
* @zh 计算变长整数所需的字节数
|
||||
* @en Calculate bytes needed for a varint
|
||||
*
|
||||
* @param value - @zh 整数值 @en Integer value
|
||||
* @returns @zh 所需字节数 @en Bytes needed
|
||||
*/
|
||||
export function varintSize(value: number): number {
|
||||
if (value < 0) {
|
||||
throw new Error('Varint only supports non-negative integers');
|
||||
}
|
||||
if (value < 128) return 1;
|
||||
if (value < 16384) return 2;
|
||||
if (value < 2097152) return 3;
|
||||
if (value < 268435456) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码变长整数到字节数组
|
||||
* @en Encode varint to byte array
|
||||
*
|
||||
* @param value - @zh 要编码的整数 @en Integer to encode
|
||||
* @param buffer - @zh 目标缓冲区 @en Target buffer
|
||||
* @param offset - @zh 写入偏移 @en Write offset
|
||||
* @returns @zh 写入后的新偏移 @en New offset after writing
|
||||
*/
|
||||
export function encodeVarint(value: number, buffer: Uint8Array, offset: number): number {
|
||||
if (value < 0) {
|
||||
throw new Error('Varint only supports non-negative integers');
|
||||
}
|
||||
|
||||
while (value >= 0x80) {
|
||||
buffer[offset++] = (value & 0x7F) | 0x80;
|
||||
value >>>= 7;
|
||||
}
|
||||
buffer[offset++] = value;
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从字节数组解码变长整数
|
||||
* @en Decode varint from byte array
|
||||
*
|
||||
* @param buffer - @zh 源缓冲区 @en Source buffer
|
||||
* @param offset - @zh 读取偏移 @en Read offset
|
||||
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
|
||||
*/
|
||||
export function decodeVarint(buffer: Uint8Array, offset: number): [number, number] {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let byte: number;
|
||||
|
||||
do {
|
||||
if (offset >= buffer.length) {
|
||||
throw new Error('Varint decode: buffer overflow');
|
||||
}
|
||||
byte = buffer[offset++]!;
|
||||
result |= (byte & 0x7F) << shift;
|
||||
shift += 7;
|
||||
} while (byte >= 0x80);
|
||||
|
||||
return [result, offset];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有符号整数(ZigZag 编码)
|
||||
* @en Encode signed integer (ZigZag encoding)
|
||||
*
|
||||
* @zh ZigZag 编码将有符号整数映射到无符号整数:
|
||||
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
|
||||
* 这样小的负数也能用较少字节表示。
|
||||
* @en ZigZag encoding maps signed integers to unsigned:
|
||||
* 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, ...
|
||||
* This allows small negative numbers to use fewer bytes.
|
||||
*
|
||||
* @param value - @zh 有符号整数 @en Signed integer
|
||||
* @returns @zh ZigZag 编码后的值 @en ZigZag encoded value
|
||||
*/
|
||||
export function zigzagEncode(value: number): number {
|
||||
return (value << 1) ^ (value >> 31);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码有符号整数(ZigZag 解码)
|
||||
* @en Decode signed integer (ZigZag decoding)
|
||||
*
|
||||
* @param value - @zh ZigZag 编码的值 @en ZigZag encoded value
|
||||
* @returns @zh 原始有符号整数 @en Original signed integer
|
||||
*/
|
||||
export function zigzagDecode(value: number): number {
|
||||
return (value >>> 1) ^ -(value & 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有符号变长整数
|
||||
* @en Encode signed varint
|
||||
*
|
||||
* @param value - @zh 有符号整数 @en Signed integer
|
||||
* @param buffer - @zh 目标缓冲区 @en Target buffer
|
||||
* @param offset - @zh 写入偏移 @en Write offset
|
||||
* @returns @zh 写入后的新偏移 @en New offset after writing
|
||||
*/
|
||||
export function encodeSignedVarint(value: number, buffer: Uint8Array, offset: number): number {
|
||||
return encodeVarint(zigzagEncode(value), buffer, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码有符号变长整数
|
||||
* @en Decode signed varint
|
||||
*
|
||||
* @param buffer - @zh 源缓冲区 @en Source buffer
|
||||
* @param offset - @zh 读取偏移 @en Read offset
|
||||
* @returns @zh [解码值, 新偏移] @en [decoded value, new offset]
|
||||
*/
|
||||
export function decodeSignedVarint(buffer: Uint8Array, offset: number): [number, number] {
|
||||
const [encoded, newOffset] = decodeVarint(buffer, offset);
|
||||
return [zigzagDecode(encoded), newOffset];
|
||||
}
|
||||
55
packages/framework/core/src/ECS/Sync/index.ts
Normal file
55
packages/framework/core/src/ECS/Sync/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @zh ECS 网络同步模块
|
||||
* @en ECS Network Synchronization Module
|
||||
*
|
||||
* @zh 提供基于 ECS Component 的网络状态同步功能:
|
||||
* - @sync 装饰器:标记需要同步的字段
|
||||
* - ChangeTracker:追踪字段级变更
|
||||
* - 二进制编解码器:高效的网络序列化
|
||||
*
|
||||
* @en Provides network state synchronization based on ECS Components:
|
||||
* - @sync decorator: Mark fields for synchronization
|
||||
* - ChangeTracker: Track field-level changes
|
||||
* - Binary encoder/decoder: Efficient network serialization
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
*
|
||||
* @ECSComponent('Player')
|
||||
* class PlayerComponent extends Component {
|
||||
* @sync("string") name: string = "";
|
||||
* @sync("uint16") score: number = 0;
|
||||
* @sync("float32") x: number = 0;
|
||||
* @sync("float32") y: number = 0;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
SyncType,
|
||||
SyncFieldMetadata,
|
||||
SyncMetadata,
|
||||
SyncOperation,
|
||||
TYPE_SIZES,
|
||||
SYNC_METADATA,
|
||||
CHANGE_TRACKER
|
||||
} from './types';
|
||||
|
||||
// Change Tracker
|
||||
export { ChangeTracker } from './ChangeTracker';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
sync,
|
||||
getSyncMetadata,
|
||||
hasSyncFields,
|
||||
getChangeTracker,
|
||||
initChangeTracker,
|
||||
clearChanges,
|
||||
hasChanges
|
||||
} from './decorators';
|
||||
|
||||
// Encoding
|
||||
export * from './encoding';
|
||||
127
packages/framework/core/src/ECS/Sync/types.ts
Normal file
127
packages/framework/core/src/ECS/Sync/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @zh 网络同步类型定义
|
||||
* @en Network synchronization type definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @zh 支持的同步数据类型
|
||||
* @en Supported sync data types
|
||||
*/
|
||||
export type SyncType =
|
||||
| 'boolean'
|
||||
| 'int8'
|
||||
| 'uint8'
|
||||
| 'int16'
|
||||
| 'uint16'
|
||||
| 'int32'
|
||||
| 'uint32'
|
||||
| 'float32'
|
||||
| 'float64'
|
||||
| 'string';
|
||||
|
||||
/**
|
||||
* @zh 同步字段元数据
|
||||
* @en Sync field metadata
|
||||
*/
|
||||
export interface SyncFieldMetadata {
|
||||
/**
|
||||
* @zh 字段索引(用于二进制编码)
|
||||
* @en Field index (for binary encoding)
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* @zh 字段名称
|
||||
* @en Field name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @zh 字段类型
|
||||
* @en Field type
|
||||
*/
|
||||
type: SyncType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 组件同步元数据
|
||||
* @en Component sync metadata
|
||||
*/
|
||||
export interface SyncMetadata {
|
||||
/**
|
||||
* @zh 组件类型 ID
|
||||
* @en Component type ID
|
||||
*/
|
||||
typeId: string;
|
||||
|
||||
/**
|
||||
* @zh 同步字段列表(按索引排序)
|
||||
* @en Sync fields list (sorted by index)
|
||||
*/
|
||||
fields: SyncFieldMetadata[];
|
||||
|
||||
/**
|
||||
* @zh 字段名到索引的映射
|
||||
* @en Field name to index mapping
|
||||
*/
|
||||
fieldIndexMap: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 同步操作类型
|
||||
* @en Sync operation type
|
||||
*/
|
||||
export enum SyncOperation {
|
||||
/**
|
||||
* @zh 完整快照
|
||||
* @en Full snapshot
|
||||
*/
|
||||
FULL = 0,
|
||||
|
||||
/**
|
||||
* @zh 增量更新
|
||||
* @en Delta update
|
||||
*/
|
||||
DELTA = 1,
|
||||
|
||||
/**
|
||||
* @zh 实体生成
|
||||
* @en Entity spawn
|
||||
*/
|
||||
SPAWN = 2,
|
||||
|
||||
/**
|
||||
* @zh 实体销毁
|
||||
* @en Entity despawn
|
||||
*/
|
||||
DESPAWN = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 各类型的字节大小
|
||||
* @en Byte size for each type
|
||||
*/
|
||||
export const TYPE_SIZES: Record<SyncType, number> = {
|
||||
boolean: 1,
|
||||
int8: 1,
|
||||
uint8: 1,
|
||||
int16: 2,
|
||||
uint16: 2,
|
||||
int32: 4,
|
||||
uint32: 4,
|
||||
float32: 4,
|
||||
float64: 8,
|
||||
string: -1, // 动态长度 | dynamic length
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 同步元数据的 Symbol 键
|
||||
* @en Symbol key for sync metadata
|
||||
*/
|
||||
export const SYNC_METADATA = Symbol('SyncMetadata');
|
||||
|
||||
/**
|
||||
* @zh 变更追踪器的 Symbol 键
|
||||
* @en Symbol key for change tracker
|
||||
*/
|
||||
export const CHANGE_TRACKER = Symbol('ChangeTracker');
|
||||
@@ -317,9 +317,7 @@ export class WorldManager implements IService {
|
||||
/**
|
||||
* @zh 更新所有活跃的World
|
||||
* @en Update all active Worlds
|
||||
*
|
||||
* @zh 应该在每帧的游戏循环中调用
|
||||
* @en Should be called in each frame of game loop
|
||||
* @internal 由 Core.update() 调用,用户不应直接调用
|
||||
*/
|
||||
public updateAll(): void {
|
||||
if (!this._isRunning) return;
|
||||
|
||||
@@ -57,3 +57,6 @@ export { EpochManager } from './Core/EpochManager';
|
||||
// Compiled Query
|
||||
export { CompiledQuery } from './Core/Query/CompiledQuery';
|
||||
export type { InstanceTypes } from './Core/Query/CompiledQuery';
|
||||
|
||||
// Network Synchronization
|
||||
export * from './Sync';
|
||||
|
||||
172
packages/framework/core/tests/ECS/Sync/ChangeTracker.test.ts
Normal file
172
packages/framework/core/tests/ECS/Sync/ChangeTracker.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ChangeTracker } from '../../../src/ECS/Sync/ChangeTracker';
|
||||
|
||||
describe('ChangeTracker - 变更追踪器测试', () => {
|
||||
let tracker: ChangeTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new ChangeTracker();
|
||||
});
|
||||
|
||||
describe('基本功能', () => {
|
||||
test('初始状态应该没有变更', () => {
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.getDirtyFields()).toEqual([]);
|
||||
});
|
||||
|
||||
test('setDirty 应该标记字段为脏', () => {
|
||||
tracker.setDirty(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
expect(tracker.getDirtyFields()).toEqual([0]);
|
||||
});
|
||||
|
||||
test('多次 setDirty 同一字段应该只记录一次', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(0);
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
expect(tracker.getDirtyFields()).toEqual([0]);
|
||||
});
|
||||
|
||||
test('setDirty 不同字段应该都被记录', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(3);
|
||||
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirty 方法', () => {
|
||||
test('未标记的字段应该返回 false', () => {
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
expect(tracker.isDirty(5)).toBe(false);
|
||||
});
|
||||
|
||||
test('已标记的字段应该返回 true', () => {
|
||||
tracker.setDirty(3);
|
||||
|
||||
expect(tracker.isDirty(3)).toBe(true);
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear 方法', () => {
|
||||
test('clear 应该清除所有变更', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
|
||||
tracker.clear();
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.getDirtyFields()).toEqual([]);
|
||||
});
|
||||
|
||||
test('clear 应该更新 lastSyncTime', () => {
|
||||
const before = tracker.lastSyncTime;
|
||||
tracker.setDirty(0);
|
||||
tracker.clear();
|
||||
|
||||
expect(tracker.lastSyncTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearField 方法', () => {
|
||||
test('clearField 应该只清除指定字段', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.setDirty(2);
|
||||
|
||||
tracker.clearField(1);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.isDirty(1)).toBe(false);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(2);
|
||||
});
|
||||
|
||||
test('清除最后一个字段应该使 hasChanges 返回 false', () => {
|
||||
tracker.setDirty(0);
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
|
||||
tracker.clearField(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllDirty 方法', () => {
|
||||
test('markAllDirty 应该标记所有字段', () => {
|
||||
tracker.markAllDirty(5);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(5);
|
||||
expect(tracker.getDirtyFields().sort()).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('markAllDirty(0) 应该没有变更', () => {
|
||||
tracker.markAllDirty(0);
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('markAllDirty 用于首次同步', () => {
|
||||
tracker.markAllDirty(3);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(true);
|
||||
expect(tracker.isDirty(1)).toBe(true);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.isDirty(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset 方法', () => {
|
||||
test('reset 应该重置所有状态', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.clear();
|
||||
|
||||
tracker.reset();
|
||||
|
||||
expect(tracker.hasChanges()).toBe(false);
|
||||
expect(tracker.getDirtyCount()).toBe(0);
|
||||
expect(tracker.lastSyncTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
test('大量字段标记应该正常工作', () => {
|
||||
const fieldCount = 1000;
|
||||
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
tracker.setDirty(i);
|
||||
}
|
||||
|
||||
expect(tracker.getDirtyCount()).toBe(fieldCount);
|
||||
expect(tracker.hasChanges()).toBe(true);
|
||||
});
|
||||
|
||||
test('交替设置和清除应该正常工作', () => {
|
||||
tracker.setDirty(0);
|
||||
tracker.setDirty(1);
|
||||
tracker.clearField(0);
|
||||
tracker.setDirty(2);
|
||||
tracker.clearField(1);
|
||||
|
||||
expect(tracker.isDirty(0)).toBe(false);
|
||||
expect(tracker.isDirty(1)).toBe(false);
|
||||
expect(tracker.isDirty(2)).toBe(true);
|
||||
expect(tracker.getDirtyCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
327
packages/framework/core/tests/ECS/Sync/decorators.test.ts
Normal file
327
packages/framework/core/tests/ECS/Sync/decorators.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import {
|
||||
sync,
|
||||
getSyncMetadata,
|
||||
hasSyncFields,
|
||||
getChangeTracker,
|
||||
initChangeTracker,
|
||||
clearChanges,
|
||||
hasChanges
|
||||
} from '../../../src/ECS/Sync/decorators';
|
||||
import { SYNC_METADATA, CHANGE_TRACKER } from '../../../src/ECS/Sync/types';
|
||||
|
||||
@ECSComponent('SyncTest_PlayerComponent')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
|
||||
localData: string = "not synced";
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_SimpleComponent')
|
||||
class SimpleComponent extends Component {
|
||||
@sync("boolean") active: boolean = true;
|
||||
@sync("int32") value: number = 100;
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_NoSyncComponent')
|
||||
class NoSyncComponent extends Component {
|
||||
localValue: number = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('SyncTest_AllTypesComponent')
|
||||
class AllTypesComponent extends Component {
|
||||
@sync("boolean") boolField: boolean = false;
|
||||
@sync("int8") int8Field: number = 0;
|
||||
@sync("uint8") uint8Field: number = 0;
|
||||
@sync("int16") int16Field: number = 0;
|
||||
@sync("uint16") uint16Field: number = 0;
|
||||
@sync("int32") int32Field: number = 0;
|
||||
@sync("uint32") uint32Field: number = 0;
|
||||
@sync("float32") float32Field: number = 0;
|
||||
@sync("float64") float64Field: number = 0;
|
||||
@sync("string") stringField: string = "";
|
||||
}
|
||||
|
||||
describe('@sync 装饰器测试', () => {
|
||||
describe('getSyncMetadata', () => {
|
||||
test('应该返回带 @sync 字段的组件元数据', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.typeId).toBe('SyncTest_PlayerComponent');
|
||||
expect(metadata!.fields.length).toBe(4);
|
||||
});
|
||||
|
||||
test('应该正确记录字段信息', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
const nameField = metadata!.fields.find(f => f.name === 'name');
|
||||
expect(nameField).toBeDefined();
|
||||
expect(nameField!.type).toBe('string');
|
||||
expect(nameField!.index).toBe(0);
|
||||
|
||||
const scoreField = metadata!.fields.find(f => f.name === 'score');
|
||||
expect(scoreField).toBeDefined();
|
||||
expect(scoreField!.type).toBe('uint16');
|
||||
|
||||
const xField = metadata!.fields.find(f => f.name === 'x');
|
||||
expect(xField).toBeDefined();
|
||||
expect(xField!.type).toBe('float32');
|
||||
});
|
||||
|
||||
test('没有 @sync 字段的组件应该返回 null', () => {
|
||||
const metadata = getSyncMetadata(NoSyncComponent);
|
||||
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
|
||||
test('可以从实例获取元数据', () => {
|
||||
const component = new PlayerComponent();
|
||||
const metadata = getSyncMetadata(component);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.fields.length).toBe(4);
|
||||
});
|
||||
|
||||
test('fieldIndexMap 应该正确映射字段名到索引', () => {
|
||||
const metadata = getSyncMetadata(PlayerComponent);
|
||||
|
||||
expect(metadata!.fieldIndexMap.get('name')).toBe(0);
|
||||
expect(metadata!.fieldIndexMap.get('score')).toBe(1);
|
||||
expect(metadata!.fieldIndexMap.get('x')).toBe(2);
|
||||
expect(metadata!.fieldIndexMap.get('y')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSyncFields', () => {
|
||||
test('有 @sync 字段应该返回 true', () => {
|
||||
expect(hasSyncFields(PlayerComponent)).toBe(true);
|
||||
expect(hasSyncFields(new PlayerComponent())).toBe(true);
|
||||
});
|
||||
|
||||
test('没有 @sync 字段应该返回 false', () => {
|
||||
expect(hasSyncFields(NoSyncComponent)).toBe(false);
|
||||
expect(hasSyncFields(new NoSyncComponent())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('支持所有同步类型', () => {
|
||||
test('AllTypesComponent 应该有所有类型的字段', () => {
|
||||
const metadata = getSyncMetadata(AllTypesComponent);
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata!.fields.length).toBe(10);
|
||||
|
||||
const types = metadata!.fields.map(f => f.type);
|
||||
expect(types).toContain('boolean');
|
||||
expect(types).toContain('int8');
|
||||
expect(types).toContain('uint8');
|
||||
expect(types).toContain('int16');
|
||||
expect(types).toContain('uint16');
|
||||
expect(types).toContain('int32');
|
||||
expect(types).toContain('uint32');
|
||||
expect(types).toContain('float32');
|
||||
expect(types).toContain('float64');
|
||||
expect(types).toContain('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('字段值拦截', () => {
|
||||
test('修改 @sync 字段应该触发变更追踪', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker).not.toBeNull();
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "TestPlayer";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.isDirty(0)).toBe(true);
|
||||
});
|
||||
|
||||
test('设置相同值不应该触发变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
component.name = "Test";
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "Test";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
test('修改非 @sync 字段不应该触发变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.localData = "new value";
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
});
|
||||
|
||||
test('多个字段变更应该都被追踪', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
component.name = "NewName";
|
||||
component.score = 100;
|
||||
component.x = 1.5;
|
||||
|
||||
expect(tracker!.getDirtyCount()).toBe(3);
|
||||
expect(tracker!.isDirty(0)).toBe(true);
|
||||
expect(tracker!.isDirty(1)).toBe(true);
|
||||
expect(tracker!.isDirty(2)).toBe(true);
|
||||
expect(tracker!.isDirty(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initChangeTracker', () => {
|
||||
test('应该创建变更追踪器', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(getChangeTracker(component)).toBeNull();
|
||||
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(getChangeTracker(component)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('应该标记所有字段为脏(用于首次同步)', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.getDirtyCount()).toBe(4);
|
||||
});
|
||||
|
||||
test('对没有 @sync 字段的组件应该抛出错误', () => {
|
||||
const component = new NoSyncComponent();
|
||||
|
||||
expect(() => {
|
||||
initChangeTracker(component);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('重复初始化应该重新标记所有字段', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
tracker!.clear();
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(false);
|
||||
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
expect(tracker!.getDirtyCount()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearChanges', () => {
|
||||
test('应该清除所有变更标记', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
|
||||
clearChanges(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
|
||||
test('对没有追踪器的组件应该安全执行', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(() => {
|
||||
clearChanges(component);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
test('初始化后应该有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
});
|
||||
|
||||
test('清除后应该没有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
|
||||
test('修改字段后应该有变更', () => {
|
||||
const component = new PlayerComponent();
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
component.score = 999;
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
});
|
||||
|
||||
test('没有追踪器应该返回 false', () => {
|
||||
const component = new PlayerComponent();
|
||||
|
||||
expect(hasChanges(component)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('与实体集成', () => {
|
||||
let scene: Scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
test('添加到实体的组件应该能正常工作', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
const component = new PlayerComponent();
|
||||
|
||||
entity.addComponent(component);
|
||||
initChangeTracker(component);
|
||||
|
||||
component.name = "EntityPlayer";
|
||||
component.x = 100;
|
||||
|
||||
const tracker = getChangeTracker(component);
|
||||
expect(tracker!.hasChanges()).toBe(true);
|
||||
});
|
||||
|
||||
test('从实体获取的组件应该保持追踪状态', () => {
|
||||
const entity = scene.createEntity('TestEntity');
|
||||
const component = new PlayerComponent();
|
||||
|
||||
entity.addComponent(component);
|
||||
initChangeTracker(component);
|
||||
clearChanges(component);
|
||||
|
||||
const retrieved = entity.getComponent(PlayerComponent);
|
||||
retrieved!.score = 50;
|
||||
|
||||
expect(hasChanges(component)).toBe(true);
|
||||
expect(hasChanges(retrieved!)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
530
packages/framework/core/tests/ECS/Sync/encoding.test.ts
Normal file
530
packages/framework/core/tests/ECS/Sync/encoding.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { BinaryWriter } from '../../../src/ECS/Sync/encoding/BinaryWriter';
|
||||
import { BinaryReader } from '../../../src/ECS/Sync/encoding/BinaryReader';
|
||||
import { Component } from '../../../src/ECS/Component';
|
||||
import { ECSComponent } from '../../../src/ECS/Decorators';
|
||||
import { Scene } from '../../../src/ECS/Scene';
|
||||
import { sync, initChangeTracker, clearChanges } from '../../../src/ECS/Sync/decorators';
|
||||
import {
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
encodeDespawnBatch
|
||||
} from '../../../src/ECS/Sync/encoding/Encoder';
|
||||
import {
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
processDespawn
|
||||
} from '../../../src/ECS/Sync/encoding/Decoder';
|
||||
import { SyncOperation } from '../../../src/ECS/Sync/types';
|
||||
|
||||
@ECSComponent('EncodingTest_PlayerComponent')
|
||||
class PlayerComponent extends Component {
|
||||
@sync("string") name: string = "";
|
||||
@sync("uint16") score: number = 0;
|
||||
@sync("float32") x: number = 0;
|
||||
@sync("float32") y: number = 0;
|
||||
}
|
||||
|
||||
@ECSComponent('EncodingTest_AllTypesComponent')
|
||||
class AllTypesComponent extends Component {
|
||||
@sync("boolean") boolField: boolean = false;
|
||||
@sync("int8") int8Field: number = 0;
|
||||
@sync("uint8") uint8Field: number = 0;
|
||||
@sync("int16") int16Field: number = 0;
|
||||
@sync("uint16") uint16Field: number = 0;
|
||||
@sync("int32") int32Field: number = 0;
|
||||
@sync("uint32") uint32Field: number = 0;
|
||||
@sync("float32") float32Field: number = 0;
|
||||
@sync("float64") float64Field: number = 0;
|
||||
@sync("string") stringField: string = "";
|
||||
}
|
||||
|
||||
describe('BinaryWriter/BinaryReader - 二进制读写器测试', () => {
|
||||
describe('基本数值类型', () => {
|
||||
test('writeUint8/readUint8', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(0);
|
||||
writer.writeUint8(127);
|
||||
writer.writeUint8(255);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint8()).toBe(0);
|
||||
expect(reader.readUint8()).toBe(127);
|
||||
expect(reader.readUint8()).toBe(255);
|
||||
});
|
||||
|
||||
test('writeInt8/readInt8', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt8(-128);
|
||||
writer.writeInt8(0);
|
||||
writer.writeInt8(127);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt8()).toBe(-128);
|
||||
expect(reader.readInt8()).toBe(0);
|
||||
expect(reader.readInt8()).toBe(127);
|
||||
});
|
||||
|
||||
test('writeBoolean/readBoolean', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeBoolean(true);
|
||||
writer.writeBoolean(false);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readBoolean()).toBe(true);
|
||||
expect(reader.readBoolean()).toBe(false);
|
||||
});
|
||||
|
||||
test('writeUint16/readUint16', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint16(0);
|
||||
writer.writeUint16(32767);
|
||||
writer.writeUint16(65535);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint16()).toBe(0);
|
||||
expect(reader.readUint16()).toBe(32767);
|
||||
expect(reader.readUint16()).toBe(65535);
|
||||
});
|
||||
|
||||
test('writeInt16/readInt16', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt16(-32768);
|
||||
writer.writeInt16(0);
|
||||
writer.writeInt16(32767);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt16()).toBe(-32768);
|
||||
expect(reader.readInt16()).toBe(0);
|
||||
expect(reader.readInt16()).toBe(32767);
|
||||
});
|
||||
|
||||
test('writeUint32/readUint32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(0);
|
||||
writer.writeUint32(2147483647);
|
||||
writer.writeUint32(4294967295);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readUint32()).toBe(0);
|
||||
expect(reader.readUint32()).toBe(2147483647);
|
||||
expect(reader.readUint32()).toBe(4294967295);
|
||||
});
|
||||
|
||||
test('writeInt32/readInt32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeInt32(-2147483648);
|
||||
writer.writeInt32(0);
|
||||
writer.writeInt32(2147483647);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readInt32()).toBe(-2147483648);
|
||||
expect(reader.readInt32()).toBe(0);
|
||||
expect(reader.readInt32()).toBe(2147483647);
|
||||
});
|
||||
|
||||
test('writeFloat32/readFloat32', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeFloat32(0);
|
||||
writer.writeFloat32(3.14);
|
||||
writer.writeFloat32(-100.5);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readFloat32()).toBe(0);
|
||||
expect(reader.readFloat32()).toBeCloseTo(3.14, 5);
|
||||
expect(reader.readFloat32()).toBeCloseTo(-100.5, 5);
|
||||
});
|
||||
|
||||
test('writeFloat64/readFloat64', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeFloat64(0);
|
||||
writer.writeFloat64(Math.PI);
|
||||
writer.writeFloat64(-1e100);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readFloat64()).toBe(0);
|
||||
expect(reader.readFloat64()).toBe(Math.PI);
|
||||
expect(reader.readFloat64()).toBe(-1e100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('变长整数 (Varint)', () => {
|
||||
test('小值 (1字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(0);
|
||||
writer.writeVarint(127);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(0);
|
||||
expect(reader.readVarint()).toBe(127);
|
||||
});
|
||||
|
||||
test('中等值 (2字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(128);
|
||||
writer.writeVarint(16383);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(128);
|
||||
expect(reader.readVarint()).toBe(16383);
|
||||
});
|
||||
|
||||
test('大值 (多字节)', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeVarint(16384);
|
||||
writer.writeVarint(1000000);
|
||||
writer.writeVarint(2147483647);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readVarint()).toBe(16384);
|
||||
expect(reader.readVarint()).toBe(1000000);
|
||||
expect(reader.readVarint()).toBe(2147483647);
|
||||
});
|
||||
});
|
||||
|
||||
describe('字符串', () => {
|
||||
test('空字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("");
|
||||
});
|
||||
|
||||
test('ASCII 字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("Hello, World!");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test('Unicode 字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("你好世界");
|
||||
writer.writeString("日本語テスト");
|
||||
writer.writeString("emoji: 🎮🎯");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("你好世界");
|
||||
expect(reader.readString()).toBe("日本語テスト");
|
||||
expect(reader.readString()).toBe("emoji: 🎮🎯");
|
||||
});
|
||||
|
||||
test('混合字符串', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeString("Player_玩家_プレイヤー");
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.readString()).toBe("Player_玩家_プレイヤー");
|
||||
});
|
||||
});
|
||||
|
||||
describe('字节数组', () => {
|
||||
test('writeBytes/readBytes', () => {
|
||||
const writer = new BinaryWriter();
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
writer.writeBytes(data);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
const result = reader.readBytes(5);
|
||||
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BinaryReader 辅助方法', () => {
|
||||
test('remaining 应该返回剩余字节数', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(100);
|
||||
writer.writeUint32(200);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.remaining).toBe(8);
|
||||
|
||||
reader.readUint32();
|
||||
expect(reader.remaining).toBe(4);
|
||||
});
|
||||
|
||||
test('hasMore 应该正确判断', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(1);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.hasMore()).toBe(true);
|
||||
|
||||
reader.readUint8();
|
||||
expect(reader.hasMore()).toBe(false);
|
||||
});
|
||||
|
||||
test('peekUint8 不应该移动读取位置', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(42);
|
||||
writer.writeUint8(99);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
expect(reader.peekUint8()).toBe(42);
|
||||
expect(reader.peekUint8()).toBe(42);
|
||||
expect(reader.offset).toBe(0);
|
||||
});
|
||||
|
||||
test('skip 应该跳过指定字节', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint8(1);
|
||||
writer.writeUint8(2);
|
||||
writer.writeUint8(3);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
reader.skip(2);
|
||||
expect(reader.readUint8()).toBe(3);
|
||||
});
|
||||
|
||||
test('读取超出范围应该抛出错误', () => {
|
||||
const reader = new BinaryReader(new Uint8Array([1, 2]));
|
||||
|
||||
expect(() => reader.readUint32()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BinaryWriter 自动扩容', () => {
|
||||
test('应该自动扩容', () => {
|
||||
const writer = new BinaryWriter(4);
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
writer.writeUint32(i);
|
||||
}
|
||||
|
||||
expect(writer.offset).toBe(400);
|
||||
|
||||
const reader = new BinaryReader(writer.toUint8Array());
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(reader.readUint32()).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test('reset 应该清空数据但保留缓冲区', () => {
|
||||
const writer = new BinaryWriter();
|
||||
writer.writeUint32(100);
|
||||
writer.writeUint32(200);
|
||||
|
||||
expect(writer.offset).toBe(8);
|
||||
|
||||
writer.reset();
|
||||
|
||||
expect(writer.offset).toBe(0);
|
||||
expect(writer.toUint8Array().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Encoder/Decoder - 实体编解码测试', () => {
|
||||
let scene: Scene;
|
||||
|
||||
// Components are auto-registered via @ECSComponent decorator
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new Scene();
|
||||
});
|
||||
|
||||
describe('encodeSnapshot/decodeSnapshot', () => {
|
||||
test('应该编码和解码单个实体', () => {
|
||||
const entity = scene.createEntity('Player1');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TestPlayer";
|
||||
comp.score = 100;
|
||||
comp.x = 10.5;
|
||||
comp.y = 20.5;
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.operation).toBe(SyncOperation.FULL);
|
||||
expect(result.entities.length).toBe(1);
|
||||
expect(result.entities[0].isNew).toBe(true);
|
||||
|
||||
const decodedEntity = targetScene.entities.buffer[0];
|
||||
expect(decodedEntity).toBeDefined();
|
||||
|
||||
const decodedComp = decodedEntity!.getComponent(PlayerComponent);
|
||||
expect(decodedComp).not.toBeNull();
|
||||
expect(decodedComp!.name).toBe("TestPlayer");
|
||||
expect(decodedComp!.score).toBe(100);
|
||||
expect(decodedComp!.x).toBeCloseTo(10.5, 5);
|
||||
expect(decodedComp!.y).toBeCloseTo(20.5, 5);
|
||||
});
|
||||
|
||||
test('应该编码和解码多个实体', () => {
|
||||
const entity1 = scene.createEntity('Player1');
|
||||
const comp1 = entity1.addComponent(new PlayerComponent());
|
||||
comp1.name = "Player1";
|
||||
comp1.score = 50;
|
||||
initChangeTracker(comp1);
|
||||
|
||||
const entity2 = scene.createEntity('Player2');
|
||||
const comp2 = entity2.addComponent(new PlayerComponent());
|
||||
comp2.name = "Player2";
|
||||
comp2.score = 100;
|
||||
initChangeTracker(comp2);
|
||||
|
||||
const data = encodeSnapshot([entity1, entity2], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.entities.length).toBe(2);
|
||||
});
|
||||
|
||||
test('DELTA 操作应该只编码变更的字段', () => {
|
||||
const entity = scene.createEntity('Player1');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TestPlayer";
|
||||
comp.score = 0;
|
||||
initChangeTracker(comp);
|
||||
clearChanges(comp);
|
||||
|
||||
comp.score = 200;
|
||||
|
||||
const deltaData = encodeSnapshot([entity], SyncOperation.DELTA);
|
||||
|
||||
expect(deltaData[0]).toBe(SyncOperation.DELTA);
|
||||
expect(deltaData.length).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeSpawn/decodeSpawn', () => {
|
||||
test('应该编码和解码实体生成', () => {
|
||||
const entity = scene.createEntity('SpawnedEntity');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "SpawnedPlayer";
|
||||
comp.score = 50;
|
||||
comp.x = 100;
|
||||
comp.y = 200;
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSpawn(entity, 'Player');
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSpawn(targetScene, data);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.prefabType).toBe('Player');
|
||||
expect(result!.componentTypes).toContain('EncodingTest_PlayerComponent');
|
||||
|
||||
const decodedComp = result!.entity.getComponent(PlayerComponent);
|
||||
expect(decodedComp!.name).toBe("SpawnedPlayer");
|
||||
expect(decodedComp!.score).toBe(50);
|
||||
});
|
||||
|
||||
test('没有 prefabType 应该也能工作', () => {
|
||||
const entity = scene.createEntity('Entity');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSpawn(entity);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSpawn(targetScene, data);
|
||||
|
||||
expect(result!.prefabType).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeDespawn/processDespawn', () => {
|
||||
test('应该编码和处理单个实体销毁', () => {
|
||||
const targetScene = new Scene();
|
||||
const entity = targetScene.createEntity('ToBeDestroyed');
|
||||
const entityId = entity.id;
|
||||
|
||||
const data = encodeDespawn(entityId);
|
||||
|
||||
expect(data[0]).toBe(SyncOperation.DESPAWN);
|
||||
|
||||
const removedIds = processDespawn(targetScene, data);
|
||||
|
||||
expect(removedIds).toContain(entityId);
|
||||
});
|
||||
|
||||
test('应该编码和处理批量实体销毁', () => {
|
||||
const targetScene = new Scene();
|
||||
const entity1 = targetScene.createEntity('Entity1');
|
||||
const entity2 = targetScene.createEntity('Entity2');
|
||||
const entity3 = targetScene.createEntity('Entity3');
|
||||
|
||||
const data = encodeDespawnBatch([entity1.id, entity2.id, entity3.id]);
|
||||
|
||||
expect(data[0]).toBe(SyncOperation.DESPAWN);
|
||||
|
||||
const removedIds = processDespawn(targetScene, data);
|
||||
|
||||
expect(removedIds.length).toBe(3);
|
||||
expect(removedIds).toContain(entity1.id);
|
||||
expect(removedIds).toContain(entity2.id);
|
||||
expect(removedIds).toContain(entity3.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('所有同步类型编解码', () => {
|
||||
test('应该正确编解码所有类型', () => {
|
||||
const entity = scene.createEntity('AllTypes');
|
||||
const comp = entity.addComponent(new AllTypesComponent());
|
||||
comp.boolField = true;
|
||||
comp.int8Field = -100;
|
||||
comp.uint8Field = 200;
|
||||
comp.int16Field = -30000;
|
||||
comp.uint16Field = 60000;
|
||||
comp.int32Field = -2000000000;
|
||||
comp.uint32Field = 4000000000;
|
||||
comp.float32Field = 3.14159;
|
||||
comp.float64Field = Math.PI;
|
||||
comp.stringField = "测试字符串";
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
decodeSnapshot(targetScene, data);
|
||||
|
||||
const decodedEntity = targetScene.entities.buffer[0];
|
||||
const decodedComp = decodedEntity!.getComponent(AllTypesComponent);
|
||||
|
||||
expect(decodedComp!.boolField).toBe(true);
|
||||
expect(decodedComp!.int8Field).toBe(-100);
|
||||
expect(decodedComp!.uint8Field).toBe(200);
|
||||
expect(decodedComp!.int16Field).toBe(-30000);
|
||||
expect(decodedComp!.uint16Field).toBe(60000);
|
||||
expect(decodedComp!.int32Field).toBe(-2000000000);
|
||||
expect(decodedComp!.uint32Field).toBe(4000000000);
|
||||
expect(decodedComp!.float32Field).toBeCloseTo(3.14159, 4);
|
||||
expect(decodedComp!.float64Field).toBe(Math.PI);
|
||||
expect(decodedComp!.stringField).toBe("测试字符串");
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
test('空实体列表应该能编码', () => {
|
||||
const data = encodeSnapshot([], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const result = decodeSnapshot(targetScene, data);
|
||||
|
||||
expect(result.entities.length).toBe(0);
|
||||
});
|
||||
|
||||
test('entityMap 应该正确跟踪实体', () => {
|
||||
const entity = scene.createEntity('Tracked');
|
||||
const comp = entity.addComponent(new PlayerComponent());
|
||||
comp.name = "TrackedPlayer";
|
||||
initChangeTracker(comp);
|
||||
|
||||
const data = encodeSnapshot([entity], SyncOperation.FULL);
|
||||
|
||||
const targetScene = new Scene();
|
||||
const entityMap = new Map();
|
||||
decodeSnapshot(targetScene, data, entityMap);
|
||||
|
||||
expect(entityMap.size).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/fsm
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/fsm",
|
||||
"version": "1.0.3",
|
||||
"version": "2.0.1",
|
||||
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,104 @@
|
||||
# @esengine/network
|
||||
|
||||
## 3.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
|
||||
|
||||
## @esengine/ecs-framework
|
||||
|
||||
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = '';
|
||||
@sync('uint16') score: number = 0;
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增导出
|
||||
- `sync` - 标记需要同步的字段装饰器
|
||||
- `SyncType` - 支持的同步类型
|
||||
- `SyncOperation` - 同步操作类型(FULL/DELTA/SPAWN/DESPAWN)
|
||||
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
|
||||
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
|
||||
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
|
||||
- `ChangeTracker` - 字段级变更追踪
|
||||
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
|
||||
|
||||
### 内部方法标记
|
||||
|
||||
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
|
||||
- `Scene.update()`
|
||||
- `SceneManager.update()`
|
||||
- `WorldManager.updateAll()`
|
||||
|
||||
## @esengine/network
|
||||
|
||||
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
// 服务端:编码状态
|
||||
const data = syncSystem.encodeAllEntities(false);
|
||||
|
||||
// 客户端:解码状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 修复
|
||||
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
|
||||
|
||||
## @esengine/server
|
||||
|
||||
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
|
||||
|
||||
```typescript
|
||||
import { ECSRoom } from '@esengine/server/ecs';
|
||||
|
||||
// 服务端启动
|
||||
Core.create();
|
||||
setInterval(() => Core.update(1 / 60), 16);
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new PhysicsSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
entity.addComponent(new PlayerComponent());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计
|
||||
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
|
||||
- `Core.update()` 统一更新 Time 和所有 World
|
||||
- `onTick()` 只处理状态同步逻辑
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 2.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/network",
|
||||
"version": "2.2.0",
|
||||
"version": "3.0.1",
|
||||
"description": "Network synchronization for multiplayer games",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
@@ -30,6 +30,15 @@
|
||||
"dependencies": {
|
||||
"@esengine/rpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/blueprint": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@esengine/blueprint": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/blueprint": "workspace:*",
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
|
||||
@@ -133,6 +133,11 @@ export type {
|
||||
EntityDeltaState,
|
||||
DeltaSyncData,
|
||||
DeltaCompressionConfig,
|
||||
// Component sync types
|
||||
ComponentSyncEventType,
|
||||
ComponentSyncEvent,
|
||||
ComponentSyncEventListener,
|
||||
ComponentSyncConfig,
|
||||
} from './sync'
|
||||
|
||||
export {
|
||||
@@ -150,6 +155,9 @@ export {
|
||||
DeltaFlags,
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor,
|
||||
// Component sync
|
||||
ComponentSyncSystem,
|
||||
createComponentSyncSystem,
|
||||
} from './sync'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
408
packages/framework/network/src/sync/ComponentSync.ts
Normal file
408
packages/framework/network/src/sync/ComponentSync.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @zh 组件同步系统
|
||||
* @en Component Sync System
|
||||
*
|
||||
* @zh 基于 @sync 装饰器的组件状态同步,与 ecs-framework 的 Sync 模块集成
|
||||
* @en Component state synchronization based on @sync decorator, integrated with ecs-framework Sync module
|
||||
*/
|
||||
|
||||
import {
|
||||
EntitySystem,
|
||||
Matcher,
|
||||
type Entity,
|
||||
// Sync types
|
||||
SyncOperation,
|
||||
SYNC_METADATA,
|
||||
CHANGE_TRACKER,
|
||||
type SyncMetadata,
|
||||
type ChangeTracker,
|
||||
// Encoding
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
decodeSnapshot,
|
||||
decodeSpawn,
|
||||
processDespawn,
|
||||
GlobalComponentRegistry,
|
||||
type DecodeSnapshotResult,
|
||||
type DecodeSpawnResult,
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity';
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件类型
|
||||
* @en Component sync event type
|
||||
*/
|
||||
export type ComponentSyncEventType =
|
||||
| 'entitySpawned'
|
||||
| 'entityDespawned'
|
||||
| 'stateUpdated';
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件
|
||||
* @en Component sync event
|
||||
*/
|
||||
export interface ComponentSyncEvent {
|
||||
type: ComponentSyncEventType;
|
||||
entityId: number;
|
||||
prefabType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 组件同步事件监听器
|
||||
* @en Component sync event listener
|
||||
*/
|
||||
export type ComponentSyncEventListener = (event: ComponentSyncEvent) => void;
|
||||
|
||||
/**
|
||||
* @zh 组件同步配置
|
||||
* @en Component sync configuration
|
||||
*/
|
||||
export interface ComponentSyncConfig {
|
||||
/**
|
||||
* @zh 是否启用增量同步
|
||||
* @en Whether to enable delta sync
|
||||
*/
|
||||
enableDeltaSync: boolean;
|
||||
|
||||
/**
|
||||
* @zh 同步间隔(毫秒)
|
||||
* @en Sync interval in milliseconds
|
||||
*/
|
||||
syncInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ComponentSyncConfig = {
|
||||
enableDeltaSync: true,
|
||||
syncInterval: 50, // 20 Hz
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ComponentSyncSystem | 组件同步系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 组件同步系统
|
||||
* @en Component sync system
|
||||
*
|
||||
* @zh 基于 @sync 装饰器自动同步组件状态
|
||||
* @en Automatically syncs component state based on @sync decorator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Server-side: broadcast state
|
||||
* const syncSystem = scene.getSystem(ComponentSyncSystem);
|
||||
* const data = syncSystem.encodeAllEntities(false); // delta
|
||||
* broadcast(data);
|
||||
*
|
||||
* // Client-side: receive state
|
||||
* const syncSystem = scene.getSystem(ComponentSyncSystem);
|
||||
* syncSystem.applySnapshot(data);
|
||||
* ```
|
||||
*/
|
||||
export class ComponentSyncSystem extends EntitySystem {
|
||||
private readonly _config: ComponentSyncConfig;
|
||||
private readonly _syncEntityMap: Map<number, Entity> = new Map();
|
||||
private readonly _syncListeners: Set<ComponentSyncEventListener> = new Set();
|
||||
private _lastSyncTime: number = 0;
|
||||
private _isServer: boolean = false;
|
||||
|
||||
constructor(config?: Partial<ComponentSyncConfig>, isServer: boolean = false) {
|
||||
super(Matcher.all(NetworkIdentity));
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
this._isServer = isServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置是否为服务端模式
|
||||
* @en Set whether in server mode
|
||||
*/
|
||||
public set isServer(value: boolean) {
|
||||
this._isServer = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取是否为服务端模式
|
||||
* @en Get whether in server mode
|
||||
*/
|
||||
public get isServer(): boolean {
|
||||
return this._isServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
public get config(): Readonly<ComponentSyncConfig> {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 添加同步事件监听器
|
||||
* @en Add sync event listener
|
||||
*/
|
||||
public addSyncListener(listener: ComponentSyncEventListener): void {
|
||||
this._syncListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除同步事件监听器
|
||||
* @en Remove sync event listener
|
||||
*/
|
||||
public removeSyncListener(listener: ComponentSyncEventListener): void {
|
||||
this._syncListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册同步组件类型
|
||||
* @en Register sync component type
|
||||
*
|
||||
* @zh 客户端需要调用此方法注册所有需要同步的组件类型
|
||||
* @en Client needs to call this to register all component types to be synced
|
||||
*/
|
||||
public registerComponent<T extends new () => any>(componentClass: T): void {
|
||||
GlobalComponentRegistry.register(componentClass as any);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Server-side: Encoding | 服务端:编码
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 编码所有实体状态
|
||||
* @en Encode all entities state
|
||||
*
|
||||
* @param fullSync - @zh 是否完整同步(首次连接时使用)@en Whether to do full sync (for initial connection)
|
||||
* @returns @zh 编码后的二进制数据 @en Encoded binary data
|
||||
*/
|
||||
public encodeAllEntities(fullSync: boolean = false): Uint8Array {
|
||||
const entities = this.getMatchingEntities();
|
||||
const operation = fullSync ? SyncOperation.FULL : SyncOperation.DELTA;
|
||||
|
||||
const data = encodeSnapshot(entities, operation);
|
||||
|
||||
// Clear change trackers after encoding delta
|
||||
if (!fullSync) {
|
||||
this._clearChangeTrackers(entities);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码有变更的实体
|
||||
* @en Encode entities with changes
|
||||
*
|
||||
* @returns @zh 编码后的二进制数据,如果没有变更返回 null @en Encoded binary data, or null if no changes
|
||||
*/
|
||||
public encodeDelta(): Uint8Array | null {
|
||||
const entities = this.getMatchingEntities();
|
||||
const changedEntities = entities.filter(entity => this._hasChanges(entity));
|
||||
|
||||
if (changedEntities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = encodeSnapshot(changedEntities, SyncOperation.DELTA);
|
||||
this._clearChangeTrackers(changedEntities);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体生成消息
|
||||
* @en Encode entity spawn message
|
||||
*/
|
||||
public encodeSpawn(entity: Entity, prefabType?: string): Uint8Array {
|
||||
return encodeSpawn(entity, prefabType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 编码实体销毁消息
|
||||
* @en Encode entity despawn message
|
||||
*/
|
||||
public encodeDespawn(entityId: number): Uint8Array {
|
||||
return encodeDespawn(entityId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Client-side: Decoding | 客户端:解码
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 应用状态快照
|
||||
* @en Apply state snapshot
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果 @en Decode result
|
||||
*/
|
||||
public applySnapshot(data: Uint8Array): DecodeSnapshotResult {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const result = decodeSnapshot(this.scene, data, this._syncEntityMap);
|
||||
|
||||
// Emit events
|
||||
for (const entityResult of result.entities) {
|
||||
if (entityResult.isNew) {
|
||||
this._emitEvent({
|
||||
type: 'entitySpawned',
|
||||
entityId: entityResult.entityId,
|
||||
});
|
||||
} else {
|
||||
this._emitEvent({
|
||||
type: 'stateUpdated',
|
||||
entityId: entityResult.entityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 应用实体生成消息
|
||||
* @en Apply entity spawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 解码结果,如果不是 SPAWN 消息返回 null @en Decode result, or null if not a SPAWN message
|
||||
*/
|
||||
public applySpawn(data: Uint8Array): DecodeSpawnResult | null {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const result = decodeSpawn(this.scene, data, this._syncEntityMap);
|
||||
|
||||
if (result) {
|
||||
this._emitEvent({
|
||||
type: 'entitySpawned',
|
||||
entityId: result.entity.id,
|
||||
prefabType: result.prefabType,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 应用实体销毁消息
|
||||
* @en Apply entity despawn message
|
||||
*
|
||||
* @param data - @zh 二进制数据 @en Binary data
|
||||
* @returns @zh 销毁的实体 ID 列表 @en List of despawned entity IDs
|
||||
*/
|
||||
public applyDespawn(data: Uint8Array): number[] {
|
||||
if (!this.scene) {
|
||||
throw new Error('ComponentSyncSystem not attached to a scene');
|
||||
}
|
||||
|
||||
const entityIds = processDespawn(this.scene, data, this._syncEntityMap);
|
||||
|
||||
for (const entityId of entityIds) {
|
||||
this._emitEvent({
|
||||
type: 'entityDespawned',
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Entity Management | 实体管理
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 通过网络 ID 获取实体
|
||||
* @en Get entity by network ID
|
||||
*/
|
||||
public getEntityById(entityId: number): Entity | undefined {
|
||||
return this._syncEntityMap.get(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有匹配的实体
|
||||
* @en Get all matching entities
|
||||
*/
|
||||
public getMatchingEntities(): Entity[] {
|
||||
return this.entities.slice();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal | 内部方法
|
||||
// =========================================================================
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// Server mode: auto-sync at interval
|
||||
if (this._isServer && this._config.enableDeltaSync) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastSyncTime >= this._config.syncInterval) {
|
||||
// Note: actual broadcast should be done by the user
|
||||
// This just updates the sync time
|
||||
this._lastSyncTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Update entity ID map
|
||||
for (const entity of entities) {
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
if (identity) {
|
||||
this._syncEntityMap.set(entity.id, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _hasChanges(entity: Entity): boolean {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker?.hasChanges()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _clearChangeTrackers(entities: Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker) {
|
||||
tracker.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitEvent(event: ComponentSyncEvent): void {
|
||||
for (const listener of this._syncListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('ComponentSyncSystem: event listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._syncEntityMap.clear();
|
||||
this._syncListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建组件同步系统
|
||||
* @en Create component sync system
|
||||
*/
|
||||
export function createComponentSyncSystem(
|
||||
config?: Partial<ComponentSyncConfig>,
|
||||
isServer: boolean = false
|
||||
): ComponentSyncSystem {
|
||||
return new ComponentSyncSystem(config, isServer);
|
||||
}
|
||||
@@ -62,3 +62,19 @@ export {
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor
|
||||
} from './StateDelta';
|
||||
|
||||
// =============================================================================
|
||||
// 组件同步 | Component Sync (@sync decorator based)
|
||||
// =============================================================================
|
||||
|
||||
export type {
|
||||
ComponentSyncEventType,
|
||||
ComponentSyncEvent,
|
||||
ComponentSyncEventListener,
|
||||
ComponentSyncConfig
|
||||
} from './ComponentSync';
|
||||
|
||||
export {
|
||||
ComponentSyncSystem,
|
||||
createComponentSyncSystem
|
||||
} from './ComponentSync';
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/pathfinding
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/pathfinding",
|
||||
"version": "1.1.0",
|
||||
"version": "2.0.1",
|
||||
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/procgen
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/procgen",
|
||||
"version": "1.0.3",
|
||||
"version": "2.0.1",
|
||||
"description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,122 @@
|
||||
# @esengine/server
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#390](https://github.com/esengine/esengine/pull/390) [`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256) Thanks [@esengine](https://github.com/esengine)! - feat: ECS 网络状态同步系统
|
||||
|
||||
## @esengine/ecs-framework
|
||||
|
||||
新增 `@sync` 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:
|
||||
|
||||
```typescript
|
||||
import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
|
||||
@ECSComponent('Player')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = '';
|
||||
@sync('uint16') score: number = 0;
|
||||
@sync('float32') x: number = 0;
|
||||
@sync('float32') y: number = 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 新增导出
|
||||
- `sync` - 标记需要同步的字段装饰器
|
||||
- `SyncType` - 支持的同步类型
|
||||
- `SyncOperation` - 同步操作类型(FULL/DELTA/SPAWN/DESPAWN)
|
||||
- `encodeSnapshot` / `decodeSnapshot` - 批量编解码
|
||||
- `encodeSpawn` / `decodeSpawn` - 实体生成编解码
|
||||
- `encodeDespawn` / `processDespawn` - 实体销毁编解码
|
||||
- `ChangeTracker` - 字段级变更追踪
|
||||
- `initChangeTracker` / `clearChanges` / `hasChanges` - 变更追踪工具函数
|
||||
|
||||
### 内部方法标记
|
||||
|
||||
将以下方法标记为 `@internal`,用户应通过 `Core.update()` 驱动更新:
|
||||
- `Scene.update()`
|
||||
- `SceneManager.update()`
|
||||
- `WorldManager.updateAll()`
|
||||
|
||||
## @esengine/network
|
||||
|
||||
新增 `ComponentSyncSystem`,基于 `@sync` 装饰器自动同步组件状态:
|
||||
|
||||
```typescript
|
||||
import { ComponentSyncSystem } from '@esengine/network';
|
||||
|
||||
// 服务端:编码状态
|
||||
const data = syncSystem.encodeAllEntities(false);
|
||||
|
||||
// 客户端:解码状态
|
||||
syncSystem.applySnapshot(data);
|
||||
```
|
||||
|
||||
### 修复
|
||||
- 将 `@esengine/ecs-framework` 从 devDependencies 移到 peerDependencies
|
||||
|
||||
## @esengine/server
|
||||
|
||||
新增 `ECSRoom`,带有 ECS World 支持的房间基类:
|
||||
|
||||
```typescript
|
||||
import { ECSRoom } from '@esengine/server/ecs';
|
||||
|
||||
// 服务端启动
|
||||
Core.create();
|
||||
setInterval(() => Core.update(1 / 60), 16);
|
||||
|
||||
// 定义房间
|
||||
class GameRoom extends ECSRoom {
|
||||
onCreate() {
|
||||
this.addSystem(new PhysicsSystem());
|
||||
}
|
||||
|
||||
onJoin(player: Player) {
|
||||
const entity = this.createPlayerEntity(player.id);
|
||||
entity.addComponent(new PlayerComponent());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 设计
|
||||
- 每个 `ECSRoom` 在 `Core.worldManager` 中创建独立的 World
|
||||
- `Core.update()` 统一更新 Time 和所有 World
|
||||
- `onTick()` 只处理状态同步逻辑
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#388](https://github.com/esengine/esengine/pull/388) [`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system
|
||||
- 新增令牌桶策略 (`TokenBucketStrategy`) - 推荐用于一般场景
|
||||
- 新增滑动窗口策略 (`SlidingWindowStrategy`) - 精确跟踪
|
||||
- 新增固定窗口策略 (`FixedWindowStrategy`) - 简单高效
|
||||
- 新增房间速率限制 mixin (`withRateLimit`)
|
||||
- 新增速率限制装饰器 (`@rateLimit`, `@noRateLimit`)
|
||||
- 新增按消息类型限流装饰器 (`@rateLimitMessage`, `@noRateLimitMessage`)
|
||||
- 支持与认证系统组合使用
|
||||
- 导出路径: `@esengine/server/ratelimit`
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#386](https://github.com/esengine/esengine/pull/386) [`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔认证系统 | add pluggable authentication system
|
||||
- 新增 JWT 认证提供者 (`createJwtAuthProvider`)
|
||||
- 新增 Session 认证提供者 (`createSessionAuthProvider`)
|
||||
- 新增服务器认证 mixin (`withAuth`)
|
||||
- 新增房间认证 mixin (`withRoomAuth`)
|
||||
- 新增认证装饰器 (`@requireAuth`, `@requireRole`)
|
||||
- 新增测试工具 (`MockAuthProvider`)
|
||||
- 导出路径: `@esengine/server/auth`, `@esengine/server/auth/testing`
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/server",
|
||||
"version": "1.1.4",
|
||||
"version": "2.0.0",
|
||||
"description": "Game server framework for ESEngine with file-based routing",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -11,9 +11,25 @@
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./auth": {
|
||||
"import": "./dist/auth/index.js",
|
||||
"types": "./dist/auth/index.d.ts"
|
||||
},
|
||||
"./auth/testing": {
|
||||
"import": "./dist/auth/testing/index.js",
|
||||
"types": "./dist/auth/testing/index.d.ts"
|
||||
},
|
||||
"./ratelimit": {
|
||||
"import": "./dist/ratelimit/index.js",
|
||||
"types": "./dist/ratelimit/index.d.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing/index.js",
|
||||
"types": "./dist/testing/index.d.ts"
|
||||
},
|
||||
"./ecs": {
|
||||
"import": "./dist/ecs/index.js",
|
||||
"types": "./dist/ecs/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -33,11 +49,24 @@
|
||||
"@esengine/rpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": ">=8.0.0"
|
||||
"ws": ">=8.0.0",
|
||||
"jsonwebtoken": ">=9.0.0",
|
||||
"@esengine/ecs-framework": ">=2.5.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jsonwebtoken": {
|
||||
"optional": true
|
||||
},
|
||||
"@esengine/ecs-framework": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MockAuthProvider, createMockAuthProvider, type MockUser } from '../testing/MockAuthProvider';
|
||||
|
||||
describe('MockAuthProvider', () => {
|
||||
const testUsers: MockUser[] = [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
{ id: '3', name: 'Charlie', roles: ['guest'] }
|
||||
];
|
||||
|
||||
let provider: MockAuthProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = createMockAuthProvider({
|
||||
users: testUsers
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic properties', () => {
|
||||
it('should have name "mock"', () => {
|
||||
expect(provider.name).toBe('mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
it('should verify existing user by id (token)', async () => {
|
||||
const result = await provider.verify('1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('1');
|
||||
expect(result.user?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return user roles', async () => {
|
||||
const result = await provider.verify('2');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.roles).toEqual(['admin', 'player']);
|
||||
});
|
||||
|
||||
it('should fail for unknown user', async () => {
|
||||
const result = await provider.verify('unknown');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should fail for empty token', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should return expiresAt', async () => {
|
||||
const result = await provider.verify('1');
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('with defaultUser', () => {
|
||||
it('should return default user for empty token', async () => {
|
||||
const providerWithDefault = createMockAuthProvider({
|
||||
defaultUser: { id: 'default', name: 'Guest' }
|
||||
});
|
||||
|
||||
const result = await providerWithDefault.verify('');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with autoCreate', () => {
|
||||
it('should auto create user for unknown token', async () => {
|
||||
const autoProvider = createMockAuthProvider({
|
||||
autoCreate: true
|
||||
});
|
||||
|
||||
const result = await autoProvider.verify('new-user-123');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('new-user-123');
|
||||
expect(result.user?.name).toBe('User_new-user-123');
|
||||
expect(result.user?.roles).toEqual(['guest']);
|
||||
});
|
||||
|
||||
it('should persist auto-created users', async () => {
|
||||
const autoProvider = createMockAuthProvider({
|
||||
autoCreate: true
|
||||
});
|
||||
|
||||
await autoProvider.verify('auto-1');
|
||||
const user = autoProvider.getUser('auto-1');
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toBe('auto-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with validateToken', () => {
|
||||
it('should validate token format', async () => {
|
||||
const validatingProvider = createMockAuthProvider({
|
||||
users: testUsers,
|
||||
validateToken: (token) => token.length >= 1 && !token.includes('invalid')
|
||||
});
|
||||
|
||||
const validResult = await validatingProvider.verify('1');
|
||||
expect(validResult.success).toBe(true);
|
||||
|
||||
const invalidResult = await validatingProvider.verify('invalid-token');
|
||||
expect(invalidResult.success).toBe(false);
|
||||
expect(invalidResult.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with delay', () => {
|
||||
it('should add artificial delay', async () => {
|
||||
const delayProvider = createMockAuthProvider({
|
||||
users: testUsers,
|
||||
delay: 50
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await delayProvider.verify('1');
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh token (returns same result as verify)', async () => {
|
||||
const result = await provider.refresh('1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke token', async () => {
|
||||
const result1 = await provider.verify('1');
|
||||
expect(result1.success).toBe(true);
|
||||
|
||||
const revoked = await provider.revoke('1');
|
||||
expect(revoked).toBe(true);
|
||||
|
||||
const result2 = await provider.verify('1');
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user management', () => {
|
||||
it('should add user', () => {
|
||||
provider.addUser({ id: '4', name: 'Dave', roles: ['tester'] });
|
||||
const user = provider.getUser('4');
|
||||
expect(user?.name).toBe('Dave');
|
||||
});
|
||||
|
||||
it('should remove user', () => {
|
||||
const removed = provider.removeUser('1');
|
||||
expect(removed).toBe(true);
|
||||
|
||||
const user = provider.getUser('1');
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false when removing non-existent user', () => {
|
||||
const removed = provider.removeUser('non-existent');
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all users', () => {
|
||||
const users = provider.getUsers();
|
||||
expect(users).toHaveLength(3);
|
||||
expect(users.map(u => u.id)).toContain('1');
|
||||
expect(users.map(u => u.id)).toContain('2');
|
||||
expect(users.map(u => u.id)).toContain('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should reset to initial state', async () => {
|
||||
provider.addUser({ id: '4', name: 'Dave' });
|
||||
await provider.revoke('1');
|
||||
|
||||
provider.clear();
|
||||
|
||||
const users = provider.getUsers();
|
||||
expect(users).toHaveLength(3);
|
||||
|
||||
const result = await provider.verify('1');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should return user id as token', () => {
|
||||
const token = provider.generateToken('user-123');
|
||||
expect(token).toBe('user-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMockAuthProvider', () => {
|
||||
it('should create provider with empty config', () => {
|
||||
const provider = createMockAuthProvider();
|
||||
expect(provider.name).toBe('mock');
|
||||
});
|
||||
|
||||
it('should create provider with custom users', () => {
|
||||
const provider = createMockAuthProvider({
|
||||
users: [{ id: 'test', name: 'Test User' }]
|
||||
});
|
||||
|
||||
const user = provider.getUser('test');
|
||||
expect(user?.name).toBe('Test User');
|
||||
});
|
||||
});
|
||||
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthContext, createGuestContext, createAuthContext, defaultUserExtractor } from '../context';
|
||||
import type { AuthResult } from '../types';
|
||||
|
||||
describe('AuthContext', () => {
|
||||
let context: AuthContext<{ id: string; name: string; roles: string[] }>;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new AuthContext();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should not be authenticated initially', () => {
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should have null user initially', () => {
|
||||
expect(context.user).toBeNull();
|
||||
});
|
||||
|
||||
it('should have null userId initially', () => {
|
||||
expect(context.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('should have empty roles initially', () => {
|
||||
expect(context.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have null authenticatedAt initially', () => {
|
||||
expect(context.authenticatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should have null expiresAt initially', () => {
|
||||
expect(context.expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAuthenticated', () => {
|
||||
it('should set authenticated state on success', () => {
|
||||
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||
};
|
||||
|
||||
context.setAuthenticated(result);
|
||||
|
||||
expect(context.isAuthenticated).toBe(true);
|
||||
expect(context.user).toEqual({ id: '123', name: 'Alice', roles: ['player'] });
|
||||
expect(context.userId).toBe('123');
|
||||
expect(context.roles).toEqual(['player']);
|
||||
expect(context.authenticatedAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('should set expiresAt when provided', () => {
|
||||
const expiresAt = Date.now() + 3600000;
|
||||
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt
|
||||
};
|
||||
|
||||
context.setAuthenticated(result);
|
||||
|
||||
expect(context.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('should clear state on failed result', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||
});
|
||||
|
||||
context.setAuthenticated({
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
expect(context.user).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear state when success but no user', () => {
|
||||
context.setAuthenticated({
|
||||
success: true
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated with expiry', () => {
|
||||
it('should return false when token is expired', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt: Date.now() - 1000
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when token is not expired', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt: Date.now() + 3600000
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role checking', () => {
|
||||
beforeEach(() => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player', 'premium'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('hasRole should return true for existing role', () => {
|
||||
expect(context.hasRole('player')).toBe(true);
|
||||
expect(context.hasRole('premium')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasRole should return false for non-existing role', () => {
|
||||
expect(context.hasRole('admin')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasAnyRole should return true if any role matches', () => {
|
||||
expect(context.hasAnyRole(['admin', 'player'])).toBe(true);
|
||||
expect(context.hasAnyRole(['guest', 'premium'])).toBe(true);
|
||||
});
|
||||
|
||||
it('hasAnyRole should return false if no role matches', () => {
|
||||
expect(context.hasAnyRole(['admin', 'moderator'])).toBe(false);
|
||||
});
|
||||
|
||||
it('hasAllRoles should return true if all roles match', () => {
|
||||
expect(context.hasAllRoles(['player', 'premium'])).toBe(true);
|
||||
});
|
||||
|
||||
it('hasAllRoles should return false if any role is missing', () => {
|
||||
expect(context.hasAllRoles(['player', 'admin'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should reset all state', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] },
|
||||
expiresAt: Date.now() + 3600000
|
||||
});
|
||||
|
||||
context.clear();
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
expect(context.user).toBeNull();
|
||||
expect(context.userId).toBeNull();
|
||||
expect(context.roles).toEqual([]);
|
||||
expect(context.authenticatedAt).toBeNull();
|
||||
expect(context.expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultUserExtractor', () => {
|
||||
describe('getId', () => {
|
||||
it('should extract id from user object', () => {
|
||||
expect(defaultUserExtractor.getId({ id: '123' })).toBe('123');
|
||||
});
|
||||
|
||||
it('should extract numeric id as string', () => {
|
||||
expect(defaultUserExtractor.getId({ id: 456 })).toBe('456');
|
||||
});
|
||||
|
||||
it('should extract userId', () => {
|
||||
expect(defaultUserExtractor.getId({ userId: 'abc' })).toBe('abc');
|
||||
});
|
||||
|
||||
it('should extract sub (JWT standard)', () => {
|
||||
expect(defaultUserExtractor.getId({ sub: 'jwt-sub' })).toBe('jwt-sub');
|
||||
});
|
||||
|
||||
it('should return empty string for invalid user', () => {
|
||||
expect(defaultUserExtractor.getId(null)).toBe('');
|
||||
expect(defaultUserExtractor.getId(undefined)).toBe('');
|
||||
expect(defaultUserExtractor.getId({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoles', () => {
|
||||
it('should extract roles array', () => {
|
||||
expect(defaultUserExtractor.getRoles({ roles: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should extract single role', () => {
|
||||
expect(defaultUserExtractor.getRoles({ role: 'admin' })).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('should filter non-string roles', () => {
|
||||
expect(defaultUserExtractor.getRoles({ roles: ['a', 123, 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid user', () => {
|
||||
expect(defaultUserExtractor.getRoles(null)).toEqual([]);
|
||||
expect(defaultUserExtractor.getRoles({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGuestContext', () => {
|
||||
it('should create unauthenticated context', () => {
|
||||
const guest = createGuestContext();
|
||||
expect(guest.isAuthenticated).toBe(false);
|
||||
expect(guest.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthContext', () => {
|
||||
it('should create authenticated context from result', () => {
|
||||
const result: AuthResult<{ id: string }> = {
|
||||
success: true,
|
||||
user: { id: '123' }
|
||||
};
|
||||
|
||||
const ctx = createAuthContext(result);
|
||||
expect(ctx.isAuthenticated).toBe(true);
|
||||
expect(ctx.userId).toBe('123');
|
||||
});
|
||||
|
||||
it('should create unauthenticated context from failed result', () => {
|
||||
const result: AuthResult<{ id: string }> = {
|
||||
success: false,
|
||||
error: 'Failed'
|
||||
};
|
||||
|
||||
const ctx = createAuthContext(result);
|
||||
expect(ctx.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { requireAuth, AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from '../decorators/requireAuth';
|
||||
import { requireRole } from '../decorators/requireRole';
|
||||
|
||||
describe('requireAuth decorator', () => {
|
||||
class TestClass {
|
||||
@requireAuth()
|
||||
basicMethod() {
|
||||
return 'basic';
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
guestAllowedMethod() {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
@requireAuth({ errorMessage: 'Custom error' })
|
||||
customErrorMethod() {
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
undecorated() {
|
||||
return 'undecorated';
|
||||
}
|
||||
}
|
||||
|
||||
let instance: TestClass;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new TestClass();
|
||||
});
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store auth metadata on target', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'basicMethod');
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should store options in metadata', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'guestAllowedMethod');
|
||||
expect(metadata?.options?.allowGuest).toBe(true);
|
||||
});
|
||||
|
||||
it('should store custom error message', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'customErrorMethod');
|
||||
expect(metadata?.options?.errorMessage).toBe('Custom error');
|
||||
});
|
||||
|
||||
it('should return undefined for undecorated methods', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'undecorated');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent methods', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'nonExistent');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
expect(instance.basicMethod()).toBe('basic');
|
||||
expect(instance.guestAllowedMethod()).toBe('guest');
|
||||
expect(instance.customErrorMethod()).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata key', () => {
|
||||
it('should use symbol for metadata storage', () => {
|
||||
expect(typeof AUTH_METADATA_KEY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('should store metadata in a Map', () => {
|
||||
const metadataMap = (TestClass.prototype as any)[AUTH_METADATA_KEY];
|
||||
expect(metadataMap).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireRole decorator', () => {
|
||||
class RoleTestClass {
|
||||
@requireRole('admin')
|
||||
adminOnly() {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
modOrAdmin() {
|
||||
return 'mod';
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
verifiedPremium() {
|
||||
return 'vip';
|
||||
}
|
||||
|
||||
@requireRole('player', { mode: 'any' })
|
||||
playerExplicit() {
|
||||
return 'player';
|
||||
}
|
||||
}
|
||||
|
||||
describe('single role', () => {
|
||||
it('should store single role as array', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.roles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('should set requireAuth to true', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to any mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.roleMode).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple roles', () => {
|
||||
it('should store multiple roles', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'modOrAdmin');
|
||||
expect(metadata?.roles).toEqual(['moderator', 'admin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role mode', () => {
|
||||
it('should support all mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'verifiedPremium');
|
||||
expect(metadata?.roleMode).toBe('all');
|
||||
expect(metadata?.roles).toEqual(['verified', 'premium']);
|
||||
});
|
||||
|
||||
it('should support explicit any mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'playerExplicit');
|
||||
expect(metadata?.roleMode).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new RoleTestClass();
|
||||
expect(instance.adminOnly()).toBe('admin');
|
||||
expect(instance.modOrAdmin()).toBe('mod');
|
||||
expect(instance.verifiedPremium()).toBe('vip');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined decorators', () => {
|
||||
class CombinedTestClass {
|
||||
@requireAuth({ allowGuest: false })
|
||||
@requireRole('admin')
|
||||
combinedMethod() {
|
||||
return 'combined';
|
||||
}
|
||||
}
|
||||
|
||||
it('should merge metadata from both decorators', () => {
|
||||
const metadata = getAuthMetadata(CombinedTestClass.prototype, 'combinedMethod');
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
expect(metadata?.roles).toEqual(['admin']);
|
||||
});
|
||||
});
|
||||
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
|
||||
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
|
||||
|
||||
describe('JwtAuthProvider', () => {
|
||||
const secret = 'test-secret-key-for-testing';
|
||||
let provider: JwtAuthProvider<{ id: string; name: string; roles: string[] }>;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = createJwtAuthProvider({
|
||||
secret,
|
||||
expiresIn: 3600
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign and verify', () => {
|
||||
it('should sign and verify a token', async () => {
|
||||
const payload = { sub: '123', name: 'Alice', roles: ['player'] };
|
||||
const token = provider.sign(payload);
|
||||
|
||||
expect(token).toBeTypeOf('string');
|
||||
expect(token.split('.')).toHaveLength(3);
|
||||
|
||||
const result = await provider.verify(token);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it('should extract user from payload', async () => {
|
||||
const payload = { sub: '123', name: 'Alice' };
|
||||
const token = provider.sign(payload);
|
||||
|
||||
const result = await provider.verify(token);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.user as any).sub).toBe('123');
|
||||
expect((result.user as any).name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return expiration time', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
const result = await provider.verify(token);
|
||||
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify errors', () => {
|
||||
it('should fail for empty token', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for invalid token', async () => {
|
||||
const result = await provider.verify('invalid.token.here');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for expired token', async () => {
|
||||
const shortLivedProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
expiresIn: -1
|
||||
});
|
||||
|
||||
const token = shortLivedProvider.sign({ sub: '123' });
|
||||
const result = await shortLivedProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for wrong secret', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
|
||||
const wrongSecretProvider = createJwtAuthProvider({
|
||||
secret: 'wrong-secret'
|
||||
});
|
||||
|
||||
const result = await wrongSecretProvider.verify(token);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with getUser callback', () => {
|
||||
it('should use getUser to transform payload', async () => {
|
||||
const customProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) || []
|
||||
})
|
||||
});
|
||||
|
||||
const token = customProvider.sign({ sub: '123', name: 'Bob', roles: ['admin'] });
|
||||
const result = await customProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual({
|
||||
id: '123',
|
||||
name: 'Bob',
|
||||
roles: ['admin']
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail when getUser returns null', async () => {
|
||||
const customProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
getUser: async () => null
|
||||
});
|
||||
|
||||
const token = customProvider.sign({ sub: '123' });
|
||||
const result = await customProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh a valid token', async () => {
|
||||
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||
|
||||
// Wait a bit so iat changes
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = await provider.refresh(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token).not.toBe(token);
|
||||
});
|
||||
|
||||
it('should return new expiration time', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
const result = await provider.refresh(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('should fail to refresh invalid token', async () => {
|
||||
const result = await provider.refresh('invalid');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode', () => {
|
||||
it('should decode token without verification', () => {
|
||||
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||
const payload = provider.decode(token);
|
||||
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload?.sub).toBe('123');
|
||||
expect(payload?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return null for invalid token', () => {
|
||||
const payload = provider.decode('not-a-token');
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionAuthProvider', () => {
|
||||
let storage: ISessionStorage;
|
||||
let provider: SessionAuthProvider<{ id: string; name: string }>;
|
||||
let storageData: Map<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
storageData = new Map();
|
||||
|
||||
storage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (storageData.get(key) as T) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
storageData.set(key, value);
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return storageData.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
provider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 3600000
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession and verify', () => {
|
||||
it('should create and verify a session', async () => {
|
||||
const user = { id: '123', name: 'Alice' };
|
||||
const sessionId = await provider.createSession(user);
|
||||
|
||||
expect(sessionId).toBeTypeOf('string');
|
||||
expect(sessionId.length).toBeGreaterThan(10);
|
||||
|
||||
const result = await provider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual(user);
|
||||
});
|
||||
|
||||
it('should store session data', async () => {
|
||||
const user = { id: '123', name: 'Alice' };
|
||||
const sessionId = await provider.createSession(user, { customField: 'value' });
|
||||
|
||||
const session = await provider.getSession(sessionId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.user).toEqual(user);
|
||||
expect(session?.data?.customField).toBe('value');
|
||||
expect(session?.createdAt).toBeTypeOf('number');
|
||||
expect(session?.lastActiveAt).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify errors', () => {
|
||||
it('should fail for empty session id', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for non-existent session', async () => {
|
||||
const result = await provider.verify('non-existent-session');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with validateUser', () => {
|
||||
it('should validate user on verify', async () => {
|
||||
const validatingProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
validateUser: (user) => user.id !== 'banned'
|
||||
});
|
||||
|
||||
const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
|
||||
const result = await validatingProvider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('ACCOUNT_DISABLED');
|
||||
});
|
||||
|
||||
it('should pass validation for valid user', async () => {
|
||||
const validatingProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
validateUser: (user) => user.id !== 'banned'
|
||||
});
|
||||
|
||||
const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
|
||||
const result = await validatingProvider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh session and update lastActiveAt', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const session1 = await provider.getSession(sessionId);
|
||||
const lastActive1 = session1?.lastActiveAt;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const result = await provider.refresh(sessionId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const session2 = await provider.getSession(sessionId);
|
||||
expect(session2?.lastActiveAt).toBeGreaterThanOrEqual(lastActive1!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke session', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const revoked = await provider.revoke(sessionId);
|
||||
expect(revoked).toBe(true);
|
||||
|
||||
const result = await provider.verify(sessionId);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSession', () => {
|
||||
it('should update session data', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const updated = await provider.updateSession(sessionId, { newField: 'newValue' });
|
||||
expect(updated).toBe(true);
|
||||
|
||||
const session = await provider.getSession(sessionId);
|
||||
expect(session?.data?.newField).toBe('newValue');
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const updated = await provider.updateSession('non-existent', { field: 'value' });
|
||||
expect(updated).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createJwtAuthProvider', () => {
|
||||
it('should create provider with default options', () => {
|
||||
const provider = createJwtAuthProvider({ secret: 'test' });
|
||||
expect(provider.name).toBe('jwt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionAuthProvider', () => {
|
||||
it('should create provider with default options', () => {
|
||||
const storage: ISessionStorage = {
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => true
|
||||
};
|
||||
|
||||
const provider = createSessionAuthProvider({ storage });
|
||||
expect(provider.name).toBe('session');
|
||||
});
|
||||
});
|
||||
202
packages/framework/server/src/auth/context.ts
Normal file
202
packages/framework/server/src/auth/context.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @zh 认证上下文实现
|
||||
* @en Authentication context implementation
|
||||
*/
|
||||
|
||||
import type { IAuthContext, AuthResult } from './types.js';
|
||||
|
||||
/**
|
||||
* @zh 用户信息提取器
|
||||
* @en User info extractor
|
||||
*/
|
||||
export interface UserInfoExtractor<TUser> {
|
||||
/**
|
||||
* @zh 提取用户 ID
|
||||
* @en Extract user ID
|
||||
*/
|
||||
getId(user: TUser): string;
|
||||
|
||||
/**
|
||||
* @zh 提取用户角色
|
||||
* @en Extract user roles
|
||||
*/
|
||||
getRoles(user: TUser): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 默认用户信息提取器
|
||||
* @en Default user info extractor
|
||||
*/
|
||||
export const defaultUserExtractor: UserInfoExtractor<unknown> = {
|
||||
getId(user: unknown): string {
|
||||
if (user && typeof user === 'object') {
|
||||
const u = user as Record<string, unknown>;
|
||||
if (typeof u.id === 'string') return u.id;
|
||||
if (typeof u.id === 'number') return String(u.id);
|
||||
if (typeof u.userId === 'string') return u.userId;
|
||||
if (typeof u.userId === 'number') return String(u.userId);
|
||||
if (typeof u.sub === 'string') return u.sub;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
getRoles(user: unknown): string[] {
|
||||
if (user && typeof user === 'object') {
|
||||
const u = user as Record<string, unknown>;
|
||||
if (Array.isArray(u.roles)) {
|
||||
return u.roles.filter((r): r is string => typeof r === 'string');
|
||||
}
|
||||
if (typeof u.role === 'string') {
|
||||
return [u.role];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*
|
||||
* @zh 存储连接的认证状态
|
||||
* @en Stores authentication state for a connection
|
||||
*/
|
||||
export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
|
||||
private _isAuthenticated: boolean = false;
|
||||
private _user: TUser | null = null;
|
||||
private _userId: string | null = null;
|
||||
private _roles: string[] = [];
|
||||
private _authenticatedAt: number | null = null;
|
||||
private _expiresAt: number | null = null;
|
||||
private _extractor: UserInfoExtractor<TUser>;
|
||||
|
||||
constructor(extractor?: UserInfoExtractor<TUser>) {
|
||||
this._extractor = (extractor ?? defaultUserExtractor) as UserInfoExtractor<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已认证
|
||||
* @en Whether authenticated
|
||||
*/
|
||||
get isAuthenticated(): boolean {
|
||||
if (this._expiresAt && Date.now() > this._expiresAt) {
|
||||
return false;
|
||||
}
|
||||
return this._isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
get user(): TUser | null {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户 ID
|
||||
* @en User ID
|
||||
*/
|
||||
get userId(): string | null {
|
||||
return this._userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户角色
|
||||
* @en User roles
|
||||
*/
|
||||
get roles(): ReadonlyArray<string> {
|
||||
return this._roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证时间
|
||||
* @en Authentication timestamp
|
||||
*/
|
||||
get authenticatedAt(): number | null {
|
||||
return this._authenticatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间
|
||||
* @en Token expiration time
|
||||
*/
|
||||
get expiresAt(): number | null {
|
||||
return this._expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有指定角色
|
||||
* @en Check if has specified role
|
||||
*/
|
||||
hasRole(role: string): boolean {
|
||||
return this._roles.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有任一指定角色
|
||||
* @en Check if has any of specified roles
|
||||
*/
|
||||
hasAnyRole(roles: string[]): boolean {
|
||||
return roles.some(role => this._roles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有所有指定角色
|
||||
* @en Check if has all specified roles
|
||||
*/
|
||||
hasAllRoles(roles: string[]): boolean {
|
||||
return roles.every(role => this._roles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置认证结果
|
||||
* @en Set authentication result
|
||||
*/
|
||||
setAuthenticated(result: AuthResult<TUser>): void {
|
||||
if (result.success && result.user) {
|
||||
this._isAuthenticated = true;
|
||||
this._user = result.user;
|
||||
this._userId = this._extractor.getId(result.user);
|
||||
this._roles = this._extractor.getRoles(result.user);
|
||||
this._authenticatedAt = Date.now();
|
||||
this._expiresAt = result.expiresAt ?? null;
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除认证状态
|
||||
* @en Clear authentication state
|
||||
*/
|
||||
clear(): void {
|
||||
this._isAuthenticated = false;
|
||||
this._user = null;
|
||||
this._userId = null;
|
||||
this._roles = [];
|
||||
this._authenticatedAt = null;
|
||||
this._expiresAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建访客认证上下文
|
||||
* @en Create guest auth context
|
||||
*/
|
||||
export function createGuestContext<TUser = unknown>(): IAuthContext<TUser> {
|
||||
return new AuthContext<TUser>();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从认证结果创建认证上下文
|
||||
* @en Create auth context from auth result
|
||||
*/
|
||||
export function createAuthContext<TUser = unknown>(
|
||||
result: AuthResult<TUser>,
|
||||
extractor?: UserInfoExtractor<TUser>
|
||||
): AuthContext<TUser> {
|
||||
const context = new AuthContext<TUser>(extractor);
|
||||
context.setAuthenticated(result);
|
||||
return context;
|
||||
}
|
||||
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @zh 认证装饰器
|
||||
* @en Authentication decorators
|
||||
*/
|
||||
|
||||
export {
|
||||
requireAuth,
|
||||
getAuthMetadata,
|
||||
AUTH_METADATA_KEY,
|
||||
type AuthMetadata
|
||||
} from './requireAuth.js';
|
||||
|
||||
export { requireRole } from './requireRole.js';
|
||||
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @zh requireAuth 装饰器
|
||||
* @en requireAuth decorator
|
||||
*/
|
||||
|
||||
import type { RequireAuthOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 认证元数据键
|
||||
* @en Auth metadata key
|
||||
*/
|
||||
export const AUTH_METADATA_KEY = Symbol('authMetadata');
|
||||
|
||||
/**
|
||||
* @zh 认证元数据
|
||||
* @en Auth metadata
|
||||
*/
|
||||
export interface AuthMetadata {
|
||||
requireAuth: boolean;
|
||||
options?: RequireAuthOptions;
|
||||
roles?: string[];
|
||||
roleMode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取方法的认证元数据
|
||||
* @en Get auth metadata for method
|
||||
*/
|
||||
export function getAuthMetadata(target: any, propertyKey: string): AuthMetadata | undefined {
|
||||
const metadata = target[AUTH_METADATA_KEY] as Map<string, AuthMetadata> | undefined;
|
||||
return metadata?.get(propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置方法的认证元数据
|
||||
* @en Set auth metadata for method
|
||||
*/
|
||||
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||
if (!target[AUTH_METADATA_KEY]) {
|
||||
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||
}
|
||||
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 要求认证装饰器
|
||||
* @en Require authentication decorator
|
||||
*
|
||||
* @zh 标记方法需要认证才能访问,用于消息处理器
|
||||
* @en Marks method as requiring authentication, used for message handlers
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRoomAuth(Room) {
|
||||
* @requireAuth()
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
* // Only authenticated players can trade
|
||||
* }
|
||||
*
|
||||
* @requireAuth({ allowGuest: true })
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: ChatData, player: AuthPlayer) {
|
||||
* // Guests can also chat
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function requireAuth(options?: RequireAuthOptions): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const key = String(propertyKey);
|
||||
const existing = getAuthMetadata(target, key);
|
||||
|
||||
setAuthMetadata(target, key, {
|
||||
...existing,
|
||||
requireAuth: true,
|
||||
options
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @zh requireRole 装饰器
|
||||
* @en requireRole decorator
|
||||
*/
|
||||
|
||||
import type { RequireRoleOptions } from '../types.js';
|
||||
import { AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from './requireAuth.js';
|
||||
|
||||
/**
|
||||
* @zh 设置方法的认证元数据
|
||||
* @en Set auth metadata for method
|
||||
*/
|
||||
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||
if (!target[AUTH_METADATA_KEY]) {
|
||||
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||
}
|
||||
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 要求角色装饰器
|
||||
* @en Require role decorator
|
||||
*
|
||||
* @zh 标记方法需要特定角色才能访问
|
||||
* @en Marks method as requiring specific role(s)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class AdminRoom extends withRoomAuth(Room) {
|
||||
* @requireRole('admin')
|
||||
* @onMessage('Ban')
|
||||
* handleBan(data: BanData, player: AuthPlayer) {
|
||||
* // Only admins can ban
|
||||
* }
|
||||
*
|
||||
* @requireRole(['moderator', 'admin'])
|
||||
* @onMessage('Mute')
|
||||
* handleMute(data: MuteData, player: AuthPlayer) {
|
||||
* // Moderators or admins can mute
|
||||
* }
|
||||
*
|
||||
* @requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
* @onMessage('SpecialFeature')
|
||||
* handleSpecial(data: any, player: AuthPlayer) {
|
||||
* // Requires both verified AND premium roles
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function requireRole(
|
||||
roles: string | string[],
|
||||
options?: RequireRoleOptions
|
||||
): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const key = String(propertyKey);
|
||||
const existing = getAuthMetadata(target, key);
|
||||
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
setAuthMetadata(target, key, {
|
||||
...existing,
|
||||
requireAuth: true,
|
||||
roles: roleArray,
|
||||
roleMode: options?.mode ?? 'any',
|
||||
options
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
129
packages/framework/server/src/auth/index.ts
Normal file
129
packages/framework/server/src/auth/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @zh 认证模块
|
||||
* @en Authentication module
|
||||
*
|
||||
* @zh 为 @esengine/server 提供可插拔的认证系统
|
||||
* @en Provides pluggable authentication system for @esengine/server
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer, Room, onMessage } from '@esengine/server';
|
||||
* import {
|
||||
* withAuth,
|
||||
* withRoomAuth,
|
||||
* createJwtAuthProvider,
|
||||
* requireAuth,
|
||||
* requireRole,
|
||||
* type AuthPlayer
|
||||
* } from '@esengine/server/auth';
|
||||
*
|
||||
* // 1. Create auth provider
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: process.env.JWT_SECRET!,
|
||||
* expiresIn: 3600,
|
||||
* });
|
||||
*
|
||||
* // 2. Wrap server with auth
|
||||
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||
* provider: jwtProvider,
|
||||
* extractCredentials: (req) => {
|
||||
* const url = new URL(req.url, 'http://localhost');
|
||||
* return url.searchParams.get('token');
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // 3. Create auth-enabled room
|
||||
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player'],
|
||||
* }) {
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* console.log(`${player.user?.name} joined`);
|
||||
* }
|
||||
*
|
||||
* @requireAuth()
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
* this.broadcast('Chat', { from: player.user?.name, text: data.text });
|
||||
* }
|
||||
*
|
||||
* @requireRole('admin')
|
||||
* @onMessage('Kick')
|
||||
* handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
* this.kick(data.playerId);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* server.define('game', GameRoom);
|
||||
* await server.start();
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AuthResult,
|
||||
AuthErrorCode,
|
||||
IAuthProvider,
|
||||
IAuthContext,
|
||||
AuthConnectionData,
|
||||
AuthConnection,
|
||||
AuthApiContext,
|
||||
AuthMsgContext,
|
||||
ConnectionRequest,
|
||||
AuthServerConfig,
|
||||
AuthGameServer,
|
||||
AuthRoomConfig,
|
||||
RequireAuthOptions,
|
||||
RequireRoleOptions
|
||||
} from './types.js';
|
||||
|
||||
// Context
|
||||
export {
|
||||
AuthContext,
|
||||
createGuestContext,
|
||||
createAuthContext,
|
||||
defaultUserExtractor,
|
||||
type UserInfoExtractor
|
||||
} from './context.js';
|
||||
|
||||
// Providers
|
||||
export {
|
||||
JwtAuthProvider,
|
||||
createJwtAuthProvider,
|
||||
type JwtAuthConfig,
|
||||
type JwtPayload
|
||||
} from './providers/JwtAuthProvider.js';
|
||||
|
||||
export {
|
||||
SessionAuthProvider,
|
||||
createSessionAuthProvider,
|
||||
type SessionAuthConfig,
|
||||
type SessionData,
|
||||
type ISessionStorage
|
||||
} from './providers/SessionAuthProvider.js';
|
||||
|
||||
// Mixins
|
||||
export {
|
||||
withAuth,
|
||||
getAuthContext,
|
||||
setAuthContext,
|
||||
requireAuthentication,
|
||||
requireRole as requireRoleCheck
|
||||
} from './mixin/withAuth.js';
|
||||
|
||||
export {
|
||||
withRoomAuth,
|
||||
AuthRoomBase,
|
||||
type AuthPlayer,
|
||||
type IAuthRoom,
|
||||
type AuthRoomClass
|
||||
} from './mixin/withRoomAuth.js';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
getAuthMetadata,
|
||||
AUTH_METADATA_KEY,
|
||||
type AuthMetadata
|
||||
} from './decorators/index.js';
|
||||
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @zh 认证 Mixin
|
||||
* @en Authentication mixins
|
||||
*/
|
||||
|
||||
export {
|
||||
withAuth,
|
||||
getAuthContext,
|
||||
setAuthContext,
|
||||
requireAuthentication,
|
||||
requireRole
|
||||
} from './withAuth.js';
|
||||
|
||||
export {
|
||||
withRoomAuth,
|
||||
AuthRoomBase,
|
||||
type AuthPlayer,
|
||||
type IAuthRoom,
|
||||
type AuthRoomClass
|
||||
} from './withRoomAuth.js';
|
||||
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @zh 服务器认证 Mixin
|
||||
* @en Server authentication mixin
|
||||
*/
|
||||
|
||||
import type { ServerConnection, GameServer } from '../../types/index.js';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
AuthResult,
|
||||
AuthServerConfig,
|
||||
AuthGameServer,
|
||||
IAuthContext,
|
||||
ConnectionRequest
|
||||
} from '../types.js';
|
||||
import { AuthContext } from '../context.js';
|
||||
|
||||
/**
|
||||
* @zh 认证数据键
|
||||
* @en Auth data key
|
||||
*/
|
||||
const AUTH_CONTEXT_KEY = Symbol('authContext');
|
||||
|
||||
/**
|
||||
* @zh 获取连接的认证上下文
|
||||
* @en Get auth context for connection
|
||||
*/
|
||||
export function getAuthContext<TUser = unknown>(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||
const data = conn.data as Record<symbol, unknown>;
|
||||
return (data[AUTH_CONTEXT_KEY] as IAuthContext<TUser>) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置连接的认证上下文
|
||||
* @en Set auth context for connection
|
||||
*/
|
||||
export function setAuthContext<TUser = unknown>(conn: ServerConnection, context: IAuthContext<TUser>): void {
|
||||
const data = conn.data as Record<symbol, unknown>;
|
||||
data[AUTH_CONTEXT_KEY] = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装服务器添加认证功能
|
||||
* @en Wrap server with authentication functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为服务器添加认证能力,不修改原始服务器
|
||||
* @en Uses mixin pattern to add authentication to server without modifying original
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer } from '@esengine/server';
|
||||
* import { withAuth, createJwtAuthProvider } from '@esengine/server/auth';
|
||||
*
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: 'your-secret-key',
|
||||
* expiresIn: 3600,
|
||||
* });
|
||||
*
|
||||
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||
* provider: jwtProvider,
|
||||
* extractCredentials: (req) => {
|
||||
* const url = new URL(req.url, 'http://localhost');
|
||||
* return url.searchParams.get('token');
|
||||
* },
|
||||
* onAuthSuccess: (conn, user) => {
|
||||
* console.log(`User ${user.name} authenticated`);
|
||||
* },
|
||||
* onAuthFailure: (conn, error) => {
|
||||
* console.log(`Auth failed: ${error.error}`);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* await server.start();
|
||||
* ```
|
||||
*/
|
||||
export function withAuth<TUser = unknown>(
|
||||
server: GameServer,
|
||||
config: AuthServerConfig<TUser>
|
||||
): AuthGameServer<TUser> {
|
||||
const {
|
||||
provider,
|
||||
extractCredentials,
|
||||
autoAuthOnConnect = true,
|
||||
disconnectOnAuthFailure = false,
|
||||
onAuthSuccess,
|
||||
onAuthFailure
|
||||
} = config;
|
||||
|
||||
const originalConnections = server.connections;
|
||||
const connectionAuthMap = new WeakMap<ServerConnection, AuthContext<TUser>>();
|
||||
|
||||
const authServer: AuthGameServer<TUser> = {
|
||||
...server,
|
||||
|
||||
get authProvider(): IAuthProvider<TUser> {
|
||||
return provider;
|
||||
},
|
||||
|
||||
async authenticate(
|
||||
conn: ServerConnection,
|
||||
credentials: unknown
|
||||
): Promise<AuthResult<TUser>> {
|
||||
const result = await provider.verify(credentials as never);
|
||||
|
||||
let authContext = connectionAuthMap.get(conn);
|
||||
if (!authContext) {
|
||||
authContext = new AuthContext<TUser>();
|
||||
connectionAuthMap.set(conn, authContext);
|
||||
setAuthContext(conn, authContext);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
authContext.setAuthenticated(result);
|
||||
await onAuthSuccess?.(conn, result.user!);
|
||||
} else {
|
||||
authContext.clear();
|
||||
await onAuthFailure?.(conn, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||
return connectionAuthMap.get(conn) ?? null;
|
||||
},
|
||||
|
||||
get connections(): ReadonlyArray<ServerConnection> {
|
||||
return originalConnections;
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnConnect = (server as any)._onConnect;
|
||||
(server as any)._onConnect = async (conn: ServerConnection, req?: unknown) => {
|
||||
const authContext = new AuthContext<TUser>();
|
||||
connectionAuthMap.set(conn, authContext);
|
||||
setAuthContext(conn, authContext);
|
||||
|
||||
if (autoAuthOnConnect && extractCredentials && req) {
|
||||
try {
|
||||
const connReq = req as ConnectionRequest;
|
||||
const credentials = await extractCredentials(connReq);
|
||||
|
||||
if (credentials) {
|
||||
const result = await provider.verify(credentials as never);
|
||||
|
||||
if (result.success) {
|
||||
authContext.setAuthenticated(result);
|
||||
await onAuthSuccess?.(conn, result.user!);
|
||||
} else {
|
||||
await onAuthFailure?.(conn, result);
|
||||
|
||||
if (disconnectOnAuthFailure) {
|
||||
(conn as any).close?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error during auto-authentication:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnConnect) {
|
||||
await originalOnConnect(conn, req);
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnDisconnect = (server as any)._onDisconnect;
|
||||
(server as any)._onDisconnect = async (conn: ServerConnection) => {
|
||||
connectionAuthMap.delete(conn);
|
||||
|
||||
if (originalOnDisconnect) {
|
||||
await originalOnDisconnect(conn);
|
||||
}
|
||||
};
|
||||
|
||||
return authServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建认证中间件
|
||||
* @en Create authentication middleware
|
||||
*
|
||||
* @zh 用于在 API 处理器中检查认证状态
|
||||
* @en Used to check authentication status in API handlers
|
||||
*/
|
||||
export function requireAuthentication<TUser = unknown>(
|
||||
conn: ServerConnection,
|
||||
options?: { errorMessage?: string }
|
||||
): IAuthContext<TUser> {
|
||||
const auth = getAuthContext<TUser>(conn);
|
||||
|
||||
if (!auth || !auth.isAuthenticated) {
|
||||
throw new Error(options?.errorMessage ?? 'Authentication required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建角色检查中间件
|
||||
* @en Create role check middleware
|
||||
*/
|
||||
export function requireRole<TUser = unknown>(
|
||||
conn: ServerConnection,
|
||||
roles: string | string[],
|
||||
options?: { mode?: 'any' | 'all'; errorMessage?: string }
|
||||
): IAuthContext<TUser> {
|
||||
const auth = requireAuthentication<TUser>(conn);
|
||||
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||
const mode = options?.mode ?? 'any';
|
||||
|
||||
const hasRole = mode === 'any'
|
||||
? auth.hasAnyRole(roleArray)
|
||||
: auth.hasAllRoles(roleArray);
|
||||
|
||||
if (!hasRole) {
|
||||
throw new Error(options?.errorMessage ?? 'Insufficient permissions');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @zh 房间认证 Mixin
|
||||
* @en Room authentication mixin
|
||||
*/
|
||||
|
||||
import type { Room, Player } from '../../room/index.js';
|
||||
import type { IAuthContext, AuthRoomConfig } from '../types.js';
|
||||
import { getAuthContext } from './withAuth.js';
|
||||
import { createGuestContext } from '../context.js';
|
||||
|
||||
/**
|
||||
* @zh 带认证的玩家
|
||||
* @en Player with authentication
|
||||
*/
|
||||
export interface AuthPlayer<TUser = unknown, TData = Record<string, unknown>> extends Player<TData> {
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*/
|
||||
readonly auth: IAuthContext<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 用户信息(快捷访问)
|
||||
* @en User info (shortcut)
|
||||
*/
|
||||
readonly user: TUser | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的房间接口
|
||||
* @en Room with authentication interface
|
||||
*/
|
||||
export interface IAuthRoom<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证钩子(在 onJoin 之前调用)
|
||||
* @en Auth hook (called before onJoin)
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 获取带认证信息的玩家
|
||||
* @en Get player with auth info
|
||||
*/
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined;
|
||||
|
||||
/**
|
||||
* @zh 获取所有带认证信息的玩家
|
||||
* @en Get all players with auth info
|
||||
*/
|
||||
getAuthPlayers(): AuthPlayer<TUser>[];
|
||||
|
||||
/**
|
||||
* @zh 按角色获取玩家
|
||||
* @en Get players by role
|
||||
*/
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[];
|
||||
|
||||
/**
|
||||
* @zh 按用户 ID 获取玩家
|
||||
* @en Get player by user ID
|
||||
*/
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证房间构造器类型
|
||||
* @en Auth room constructor type
|
||||
*/
|
||||
export type AuthRoomClass<TUser = unknown> = new (...args: any[]) => Room & IAuthRoom<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 玩家认证上下文存储
|
||||
* @en Player auth context storage
|
||||
*/
|
||||
const playerAuthContexts = new WeakMap<Player, IAuthContext<unknown>>();
|
||||
|
||||
/**
|
||||
* @zh 包装玩家对象添加认证信息
|
||||
* @en Wrap player object with auth info
|
||||
*/
|
||||
function wrapPlayerWithAuth<TUser>(player: Player, authContext: IAuthContext<TUser>): AuthPlayer<TUser> {
|
||||
playerAuthContexts.set(player, authContext);
|
||||
|
||||
Object.defineProperty(player, 'auth', {
|
||||
get: () => playerAuthContexts.get(player) ?? createGuestContext<TUser>(),
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(player, 'user', {
|
||||
get: () => (playerAuthContexts.get(player) as IAuthContext<TUser> | undefined)?.user ?? null,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
return player as AuthPlayer<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装房间类添加认证功能
|
||||
* @en Wrap room class with authentication functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为房间添加认证检查,在玩家加入前验证认证状态
|
||||
* @en Uses mixin pattern to add auth checks to room, validates auth before player joins
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth';
|
||||
*
|
||||
* interface User {
|
||||
* id: string;
|
||||
* name: string;
|
||||
* roles: string[];
|
||||
* }
|
||||
*
|
||||
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player', 'premium'],
|
||||
* }) {
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* console.log(`${player.user?.name} joined the game`);
|
||||
* this.broadcast('PlayerJoined', {
|
||||
* id: player.id,
|
||||
* name: player.user?.name
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* // Optional: custom auth validation
|
||||
* async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
* // Additional validation logic
|
||||
* if (player.auth.hasRole('banned')) {
|
||||
* return false;
|
||||
* }
|
||||
* return true;
|
||||
* }
|
||||
*
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
* this.broadcast('Chat', {
|
||||
* from: player.user?.name ?? 'Guest',
|
||||
* text: data.text
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||
Base: TBase,
|
||||
config: AuthRoomConfig = {}
|
||||
): TBase & (new (...args: any[]) => IAuthRoom<TUser>) {
|
||||
const {
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
roleCheckMode = 'any'
|
||||
} = config;
|
||||
|
||||
abstract class AuthRoom extends (Base as new (...args: any[]) => Room) implements IAuthRoom<TUser> {
|
||||
private _originalOnJoin: ((player: Player) => void | Promise<void>) | undefined;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
this._originalOnJoin = this.onJoin?.bind(this);
|
||||
this.onJoin = this._authOnJoin.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证钩子(可覆盖)
|
||||
* @en Auth hook (can be overridden)
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 包装的 onJoin 方法
|
||||
* @en Wrapped onJoin method
|
||||
*/
|
||||
private async _authOnJoin(player: Player): Promise<void> {
|
||||
const conn = (player as any).connection ?? (player as any)._conn;
|
||||
const authContext = conn
|
||||
? (getAuthContext<TUser>(conn) ?? createGuestContext<TUser>())
|
||||
: createGuestContext<TUser>();
|
||||
|
||||
if (requireAuth && !authContext.isAuthenticated) {
|
||||
console.warn(`[AuthRoom] Rejected unauthenticated player: ${player.id}`);
|
||||
this.kick(player as any, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0) {
|
||||
const hasRole = roleCheckMode === 'any'
|
||||
? authContext.hasAnyRole(allowedRoles)
|
||||
: authContext.hasAllRoles(allowedRoles);
|
||||
|
||||
if (!hasRole) {
|
||||
console.warn(`[AuthRoom] Rejected player ${player.id}: insufficient roles`);
|
||||
this.kick(player as any, 'Insufficient permissions');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const authPlayer = wrapPlayerWithAuth<TUser>(player, authContext);
|
||||
|
||||
if (typeof this.onAuth === 'function') {
|
||||
try {
|
||||
const allowed = await this.onAuth(authPlayer);
|
||||
if (!allowed) {
|
||||
console.warn(`[AuthRoom] Rejected player ${player.id}: onAuth returned false`);
|
||||
this.kick(player as any, 'Authentication rejected');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AuthRoom] Error in onAuth for player ${player.id}:`, error);
|
||||
this.kick(player as any, 'Authentication error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._originalOnJoin) {
|
||||
await this._originalOnJoin(authPlayer as unknown as Player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取带认证信息的玩家
|
||||
* @en Get player with auth info
|
||||
*/
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||
const player = this.getPlayer(id);
|
||||
return player as AuthPlayer<TUser> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有带认证信息的玩家
|
||||
* @en Get all players with auth info
|
||||
*/
|
||||
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||
return this.players as AuthPlayer<TUser>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 按角色获取玩家
|
||||
* @en Get players by role
|
||||
*/
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||
return this.getAuthPlayers().filter(p => p.auth?.hasRole(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 按用户 ID 获取玩家
|
||||
* @en Get player by user ID
|
||||
*/
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||
return this.getAuthPlayers().find(p => p.auth?.userId === userId);
|
||||
}
|
||||
}
|
||||
|
||||
return AuthRoom as unknown as TBase & (new (...args: any[]) => IAuthRoom<TUser>);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 抽象认证房间基类
|
||||
* @en Abstract auth room base class
|
||||
*
|
||||
* @zh 如果不想使用 mixin,可以直接继承此类
|
||||
* @en If you don't want to use mixin, you can extend this class directly
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AuthRoomBase } from '@esengine/server/auth';
|
||||
*
|
||||
* class GameRoom extends AuthRoomBase<User> {
|
||||
* protected readonly authConfig = {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player']
|
||||
* };
|
||||
*
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* // player has .auth and .user properties
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
|
||||
implements IAuthRoom<TUser> {
|
||||
|
||||
/**
|
||||
* @zh 认证配置(子类可覆盖)
|
||||
* @en Auth config (can be overridden by subclass)
|
||||
*/
|
||||
protected readonly authConfig: AuthRoomConfig = {
|
||||
requireAuth: true,
|
||||
allowedRoles: [],
|
||||
roleCheckMode: 'any'
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 认证钩子
|
||||
* @en Auth hook
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @zh 认证提供者接口
|
||||
* @en Authentication provider interface
|
||||
*/
|
||||
|
||||
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @zh JWT 认证提供者
|
||||
* @en JWT authentication provider
|
||||
*/
|
||||
|
||||
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* @zh JWT 载荷
|
||||
* @en JWT payload
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
/**
|
||||
* @zh 主题(用户 ID)
|
||||
* @en Subject (user ID)
|
||||
*/
|
||||
sub?: string;
|
||||
|
||||
/**
|
||||
* @zh 签发时间
|
||||
* @en Issued at
|
||||
*/
|
||||
iat?: number;
|
||||
|
||||
/**
|
||||
* @zh 过期时间
|
||||
* @en Expiration time
|
||||
*/
|
||||
exp?: number;
|
||||
|
||||
/**
|
||||
* @zh 自定义字段
|
||||
* @en Custom fields
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh JWT 认证配置
|
||||
* @en JWT authentication configuration
|
||||
*/
|
||||
export interface JwtAuthConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh 密钥
|
||||
* @en Secret key
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* @zh 算法
|
||||
* @en Algorithm
|
||||
* @defaultValue 'HS256'
|
||||
*/
|
||||
algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间(秒)
|
||||
* @en Token expiration in seconds
|
||||
* @defaultValue 3600
|
||||
*/
|
||||
expiresIn?: number;
|
||||
|
||||
/**
|
||||
* @zh 从载荷获取用户信息
|
||||
* @en Get user from payload
|
||||
*/
|
||||
getUser?: (payload: JwtPayload) => TUser | Promise<TUser | null> | null;
|
||||
|
||||
/**
|
||||
* @zh 签发者
|
||||
* @en Issuer
|
||||
*/
|
||||
issuer?: string;
|
||||
|
||||
/**
|
||||
* @zh 受众
|
||||
* @en Audience
|
||||
*/
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh JWT 认证提供者
|
||||
* @en JWT authentication provider
|
||||
*
|
||||
* @zh 使用 jsonwebtoken 库实现 JWT 认证
|
||||
* @en Uses jsonwebtoken library for JWT authentication
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: 'your-secret-key',
|
||||
* expiresIn: 3600,
|
||||
* getUser: async (payload) => {
|
||||
* return await db.users.findById(payload.sub);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class JwtAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||
readonly name = 'jwt';
|
||||
|
||||
private _config: Required<Pick<JwtAuthConfig<TUser>, 'secret' | 'algorithm' | 'expiresIn'>> & JwtAuthConfig<TUser>;
|
||||
|
||||
constructor(config: JwtAuthConfig<TUser>) {
|
||||
this._config = {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: 3600,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证令牌
|
||||
* @en Verify token
|
||||
*/
|
||||
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyOptions: jwt.VerifyOptions = {
|
||||
algorithms: [this._config.algorithm]
|
||||
};
|
||||
if (this._config.issuer) {
|
||||
verifyOptions.issuer = this._config.issuer;
|
||||
}
|
||||
if (this._config.audience) {
|
||||
verifyOptions.audience = this._config.audience;
|
||||
}
|
||||
const payload = jwt.verify(token, this._config.secret, verifyOptions) as JwtPayload;
|
||||
|
||||
let user: TUser | null = null;
|
||||
|
||||
if (this._config.getUser) {
|
||||
user = await this._config.getUser(payload);
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
user = payload as unknown as TUser;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
token,
|
||||
expiresAt: payload.exp ? payload.exp * 1000 : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token has expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Invalid token',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌
|
||||
* @en Refresh token
|
||||
*/
|
||||
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||
const result = await this.verify(token);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const payload = jwt.decode(token) as JwtPayload;
|
||||
|
||||
// Remove JWT standard claims that will be regenerated
|
||||
const { iat, exp, nbf, ...restPayload } = payload;
|
||||
|
||||
const newToken = this.sign(restPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: result.user,
|
||||
token: newToken,
|
||||
expiresAt: Date.now() + this._config.expiresIn * 1000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成令牌
|
||||
* @en Generate token
|
||||
*/
|
||||
sign(payload: Record<string, unknown>): string {
|
||||
const signOptions: jwt.SignOptions = {
|
||||
algorithm: this._config.algorithm,
|
||||
expiresIn: this._config.expiresIn
|
||||
};
|
||||
if (this._config.issuer) {
|
||||
signOptions.issuer = this._config.issuer;
|
||||
}
|
||||
if (this._config.audience) {
|
||||
signOptions.audience = this._config.audience;
|
||||
}
|
||||
return jwt.sign(payload, this._config.secret, signOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码令牌(不验证)
|
||||
* @en Decode token (without verification)
|
||||
*/
|
||||
decode(token: string): JwtPayload | null {
|
||||
return jwt.decode(token) as JwtPayload | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 JWT 认证提供者
|
||||
* @en Create JWT authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createJwtAuthProvider } from '@esengine/server/auth';
|
||||
*
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: process.env.JWT_SECRET!,
|
||||
* expiresIn: 3600,
|
||||
* getUser: async (payload) => {
|
||||
* return { id: payload.sub, name: payload.name };
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createJwtAuthProvider<TUser = unknown>(
|
||||
config: JwtAuthConfig<TUser>
|
||||
): JwtAuthProvider<TUser> {
|
||||
return new JwtAuthProvider<TUser>(config);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @zh Session 认证提供者
|
||||
* @en Session authentication provider
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { IAuthProvider, AuthResult } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh Session 存储接口(兼容 ITransactionStorage)
|
||||
* @en Session storage interface (compatible with ITransactionStorage)
|
||||
*/
|
||||
export interface ISessionStorage {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 数据
|
||||
* @en Session data
|
||||
*/
|
||||
export interface SessionData<TUser = unknown> {
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
user: TUser;
|
||||
|
||||
/**
|
||||
* @zh 创建时间
|
||||
* @en Created at
|
||||
*/
|
||||
createdAt: number;
|
||||
|
||||
/**
|
||||
* @zh 最后活跃时间
|
||||
* @en Last active at
|
||||
*/
|
||||
lastActiveAt: number;
|
||||
|
||||
/**
|
||||
* @zh 自定义数据
|
||||
* @en Custom data
|
||||
*/
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 认证配置
|
||||
* @en Session authentication configuration
|
||||
*/
|
||||
export interface SessionAuthConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh Session 存储
|
||||
* @en Session storage
|
||||
*/
|
||||
storage: ISessionStorage;
|
||||
|
||||
/**
|
||||
* @zh Session 过期时间(毫秒)
|
||||
* @en Session expiration in milliseconds
|
||||
* @defaultValue 86400000 (24h)
|
||||
*/
|
||||
sessionTTL?: number;
|
||||
|
||||
/**
|
||||
* @zh Session 键前缀
|
||||
* @en Session key prefix
|
||||
* @defaultValue 'session:'
|
||||
*/
|
||||
prefix?: string;
|
||||
|
||||
/**
|
||||
* @zh 验证用户(可选额外验证)
|
||||
* @en Validate user (optional extra validation)
|
||||
*/
|
||||
validateUser?: (user: TUser) => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 是否自动续期
|
||||
* @en Auto renew session
|
||||
* @defaultValue true
|
||||
*/
|
||||
autoRenew?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 认证提供者
|
||||
* @en Session authentication provider
|
||||
*
|
||||
* @zh 基于存储的会话认证,支持 Redis、MongoDB 等后端
|
||||
* @en Storage-based session authentication, supports Redis, MongoDB, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||
* import { createRedisStorage } from '@esengine/transaction';
|
||||
*
|
||||
* const sessionProvider = createSessionAuthProvider({
|
||||
* storage: createRedisStorage({ factory: () => new Redis() }),
|
||||
* sessionTTL: 86400000, // 24 hours
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class SessionAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||
readonly name = 'session';
|
||||
|
||||
private _config: Required<Pick<SessionAuthConfig<TUser>, 'sessionTTL' | 'prefix' | 'autoRenew'>> & SessionAuthConfig<TUser>;
|
||||
|
||||
constructor(config: SessionAuthConfig<TUser>) {
|
||||
this._config = {
|
||||
sessionTTL: 86400000,
|
||||
prefix: 'session:',
|
||||
autoRenew: true,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取存储键
|
||||
* @en Get storage key
|
||||
*/
|
||||
private _getKey(sessionId: string): string {
|
||||
return `${this._config.prefix}${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证 Session
|
||||
* @en Verify session
|
||||
*/
|
||||
async verify(sessionId: string): Promise<AuthResult<TUser>> {
|
||||
if (!sessionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session ID is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session not found or expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.validateUser) {
|
||||
const isValid = await this._config.validateUser(session.user);
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User validation failed',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this._config.autoRenew) {
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: session.user,
|
||||
token: sessionId,
|
||||
expiresAt: session.createdAt + this._config.sessionTTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新 Session
|
||||
* @en Refresh session
|
||||
*/
|
||||
async refresh(sessionId: string): Promise<AuthResult<TUser>> {
|
||||
const result = await this.verify(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
expiresAt: Date.now() + this._config.sessionTTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 撤销 Session
|
||||
* @en Revoke session
|
||||
*/
|
||||
async revoke(sessionId: string): Promise<boolean> {
|
||||
const key = this._getKey(sessionId);
|
||||
return await this._config.storage.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Session
|
||||
* @en Create session
|
||||
*/
|
||||
async createSession(user: TUser, data?: Record<string, unknown>): Promise<string> {
|
||||
const sessionId = this._generateSessionId();
|
||||
const key = this._getKey(sessionId);
|
||||
|
||||
const session: SessionData<TUser> = {
|
||||
user,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
data
|
||||
};
|
||||
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取 Session 数据
|
||||
* @en Get session data
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<SessionData<TUser> | null> {
|
||||
const key = this._getKey(sessionId);
|
||||
return await this._config.storage.get<SessionData<TUser>>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新 Session 数据
|
||||
* @en Update session data
|
||||
*/
|
||||
async updateSession(sessionId: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.data = { ...session.data, ...data };
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成 Session ID(使用加密安全的随机数)
|
||||
* @en Generate session ID (using cryptographically secure random)
|
||||
*/
|
||||
private _generateSessionId(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Session 认证提供者
|
||||
* @en Create session authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||
* import { MemoryStorage } from '@esengine/transaction';
|
||||
*
|
||||
* const sessionProvider = createSessionAuthProvider({
|
||||
* storage: new MemoryStorage(),
|
||||
* sessionTTL: 3600000, // 1 hour
|
||||
* });
|
||||
*
|
||||
* // Create a session
|
||||
* const sessionId = await sessionProvider.createSession({ id: '1', name: 'Alice' });
|
||||
*
|
||||
* // Verify session
|
||||
* const result = await sessionProvider.verify(sessionId);
|
||||
* ```
|
||||
*/
|
||||
export function createSessionAuthProvider<TUser = unknown>(
|
||||
config: SessionAuthConfig<TUser>
|
||||
): SessionAuthProvider<TUser> {
|
||||
return new SessionAuthProvider<TUser>(config);
|
||||
}
|
||||
8
packages/framework/server/src/auth/providers/index.ts
Normal file
8
packages/framework/server/src/auth/providers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication providers
|
||||
*/
|
||||
|
||||
export type { IAuthProvider, AuthResult, AuthErrorCode } from './IAuthProvider.js';
|
||||
export { JwtAuthProvider, createJwtAuthProvider, type JwtAuthConfig, type JwtPayload } from './JwtAuthProvider.js';
|
||||
export { SessionAuthProvider, createSessionAuthProvider, type SessionAuthConfig, type SessionData, type ISessionStorage } from './SessionAuthProvider.js';
|
||||
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @zh 模拟认证提供者
|
||||
* @en Mock authentication provider
|
||||
*/
|
||||
|
||||
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 模拟用户
|
||||
* @en Mock user
|
||||
*/
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟认证配置
|
||||
* @en Mock authentication configuration
|
||||
*/
|
||||
export interface MockAuthConfig {
|
||||
/**
|
||||
* @zh 预设用户列表
|
||||
* @en Preset user list
|
||||
*/
|
||||
users?: MockUser[];
|
||||
|
||||
/**
|
||||
* @zh 默认用户(无 token 时返回)
|
||||
* @en Default user (returned when no token)
|
||||
*/
|
||||
defaultUser?: MockUser;
|
||||
|
||||
/**
|
||||
* @zh 模拟延迟(毫秒)
|
||||
* @en Simulated delay (ms)
|
||||
*/
|
||||
delay?: number;
|
||||
|
||||
/**
|
||||
* @zh 是否自动创建用户
|
||||
* @en Auto create users
|
||||
*/
|
||||
autoCreate?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 验证令牌格式
|
||||
* @en Token format validator
|
||||
*/
|
||||
validateToken?: (token: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟认证提供者
|
||||
* @en Mock authentication provider
|
||||
*
|
||||
* @zh 用于测试的认证提供者,不需要真实的 JWT 或数据库
|
||||
* @en Authentication provider for testing, no real JWT or database required
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockAuthProvider } from '@esengine/server/auth/testing';
|
||||
*
|
||||
* const mockProvider = createMockAuthProvider({
|
||||
* users: [
|
||||
* { id: '1', name: 'Alice', roles: ['player'] },
|
||||
* { id: '2', name: 'Bob', roles: ['admin'] },
|
||||
* ],
|
||||
* autoCreate: true, // Unknown tokens create guest users
|
||||
* });
|
||||
*
|
||||
* // Verify with user ID as token
|
||||
* const result = await mockProvider.verify('1');
|
||||
* // result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
* ```
|
||||
*/
|
||||
export class MockAuthProvider<TUser extends MockUser = MockUser>
|
||||
implements IAuthProvider<TUser, string> {
|
||||
|
||||
readonly name = 'mock';
|
||||
|
||||
private _users: Map<string, TUser>;
|
||||
private _config: MockAuthConfig;
|
||||
private _revokedTokens: Set<string> = new Set();
|
||||
|
||||
constructor(config: MockAuthConfig = {}) {
|
||||
this._config = config;
|
||||
this._users = new Map();
|
||||
|
||||
if (config.users) {
|
||||
for (const user of config.users) {
|
||||
this._users.set(user.id, user as TUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟延迟
|
||||
* @en Simulate delay
|
||||
*/
|
||||
private async _delay(): Promise<void> {
|
||||
if (this._config.delay && this._config.delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this._config.delay));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证令牌
|
||||
* @en Verify token
|
||||
*/
|
||||
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||
await this._delay();
|
||||
|
||||
if (!token) {
|
||||
if (this._config.defaultUser) {
|
||||
return {
|
||||
success: true,
|
||||
user: this._config.defaultUser as TUser,
|
||||
token: 'default'
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._revokedTokens.has(token)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token has been revoked',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.validateToken && !this._config.validateToken(token)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token format',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
const user = this._users.get(token);
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
token,
|
||||
expiresAt: Date.now() + 3600000
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.autoCreate) {
|
||||
const newUser = {
|
||||
id: token,
|
||||
name: `User_${token}`,
|
||||
roles: ['guest']
|
||||
} as TUser;
|
||||
|
||||
this._users.set(token, newUser);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
token,
|
||||
expiresAt: Date.now() + 3600000
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌
|
||||
* @en Refresh token
|
||||
*/
|
||||
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||
return this.verify(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 撤销令牌
|
||||
* @en Revoke token
|
||||
*/
|
||||
async revoke(token: string): Promise<boolean> {
|
||||
this._revokedTokens.add(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 添加用户
|
||||
* @en Add user
|
||||
*/
|
||||
addUser(user: TUser): void {
|
||||
this._users.set(user.id, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除用户
|
||||
* @en Remove user
|
||||
*/
|
||||
removeUser(id: string): boolean {
|
||||
return this._users.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取用户
|
||||
* @en Get user
|
||||
*/
|
||||
getUser(id: string): TUser | undefined {
|
||||
return this._users.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有用户
|
||||
* @en Get all users
|
||||
*/
|
||||
getUsers(): TUser[] {
|
||||
return Array.from(this._users.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清空所有状态
|
||||
* @en Clear all state
|
||||
*/
|
||||
clear(): void {
|
||||
this._users.clear();
|
||||
this._revokedTokens.clear();
|
||||
|
||||
if (this._config.users) {
|
||||
for (const user of this._config.users) {
|
||||
this._users.set(user.id, user as TUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成测试令牌
|
||||
* @en Generate test token
|
||||
*/
|
||||
generateToken(userId: string): string {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建模拟认证提供者
|
||||
* @en Create mock authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = createMockAuthProvider({
|
||||
* users: [
|
||||
* { id: 'admin', name: 'Admin', roles: ['admin'] }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* // Use in tests
|
||||
* const server = withAuth(await createServer({ port: 0 }), {
|
||||
* provider,
|
||||
* extractCredentials: (req) => req.headers['x-token']
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockAuthProvider<TUser extends MockUser = MockUser>(
|
||||
config?: MockAuthConfig
|
||||
): MockAuthProvider<TUser> {
|
||||
return new MockAuthProvider<TUser>(config);
|
||||
}
|
||||
11
packages/framework/server/src/auth/testing/index.ts
Normal file
11
packages/framework/server/src/auth/testing/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @zh 认证测试工具
|
||||
* @en Authentication testing utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
MockAuthProvider,
|
||||
createMockAuthProvider,
|
||||
type MockUser,
|
||||
type MockAuthConfig
|
||||
} from './MockAuthProvider.js';
|
||||
400
packages/framework/server/src/auth/types.ts
Normal file
400
packages/framework/server/src/auth/types.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* @zh 认证系统类型定义
|
||||
* @en Authentication system type definitions
|
||||
*/
|
||||
|
||||
import type { ServerConnection, ApiContext, MsgContext, GameServer } from '../types/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Error Codes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证错误代码
|
||||
* @en Authentication error codes
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
|
||||
// ============================================================================
|
||||
// Auth Result
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证结果
|
||||
* @en Authentication result
|
||||
*/
|
||||
export interface AuthResult<TUser = unknown> {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether succeeded
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
user?: TUser;
|
||||
|
||||
/**
|
||||
* @zh 错误信息
|
||||
* @en Error message
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* @zh 错误代码
|
||||
* @en Error code
|
||||
*/
|
||||
errorCode?: AuthErrorCode;
|
||||
|
||||
/**
|
||||
* @zh 令牌
|
||||
* @en Token
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间(时间戳)
|
||||
* @en Token expiration time (timestamp)
|
||||
*/
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Provider
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证提供者接口
|
||||
* @en Authentication provider interface
|
||||
*/
|
||||
export interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/**
|
||||
* @zh 提供者名称
|
||||
* @en Provider name
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @zh 验证凭证
|
||||
* @en Verify credentials
|
||||
*
|
||||
* @param credentials - @zh 凭证数据 @en Credential data
|
||||
* @returns @zh 验证结果 @en Verification result
|
||||
*/
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌(可选)
|
||||
* @en Refresh token (optional)
|
||||
*/
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 撤销令牌(可选)
|
||||
* @en Revoke token (optional)
|
||||
*/
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证上下文接口
|
||||
* @en Authentication context interface
|
||||
*/
|
||||
export interface IAuthContext<TUser = unknown> {
|
||||
/**
|
||||
* @zh 是否已认证
|
||||
* @en Whether authenticated
|
||||
*/
|
||||
readonly isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
readonly user: TUser | null;
|
||||
|
||||
/**
|
||||
* @zh 用户 ID
|
||||
* @en User ID
|
||||
*/
|
||||
readonly userId: string | null;
|
||||
|
||||
/**
|
||||
* @zh 用户角色
|
||||
* @en User roles
|
||||
*/
|
||||
readonly roles: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* @zh 认证时间
|
||||
* @en Authentication timestamp
|
||||
*/
|
||||
readonly authenticatedAt: number | null;
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间
|
||||
* @en Token expiration time
|
||||
*/
|
||||
readonly expiresAt: number | null;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有指定角色
|
||||
* @en Check if has specified role
|
||||
*/
|
||||
hasRole(role: string): boolean;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有任一指定角色
|
||||
* @en Check if has any of specified roles
|
||||
*/
|
||||
hasAnyRole(roles: string[]): boolean;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有所有指定角色
|
||||
* @en Check if has all specified roles
|
||||
*/
|
||||
hasAllRoles(roles: string[]): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Connection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证连接数据
|
||||
* @en Authentication connection data
|
||||
*/
|
||||
export interface AuthConnectionData<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*/
|
||||
auth: IAuthContext<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的连接
|
||||
* @en Connection with authentication
|
||||
*/
|
||||
export interface AuthConnection<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends ServerConnection<TData> {
|
||||
/**
|
||||
* @zh 认证上下文(快捷访问)
|
||||
* @en Authentication context (shortcut)
|
||||
*/
|
||||
readonly auth: IAuthContext<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的 API 上下文
|
||||
* @en API context with authentication
|
||||
*/
|
||||
export interface AuthApiContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends ApiContext<TData> {
|
||||
/**
|
||||
* @zh 当前连接(带认证)
|
||||
* @en Current connection (with auth)
|
||||
*/
|
||||
conn: AuthConnection<TUser, TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的消息上下文
|
||||
* @en Message context with authentication
|
||||
*/
|
||||
export interface AuthMsgContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends MsgContext<TData> {
|
||||
/**
|
||||
* @zh 当前连接(带认证)
|
||||
* @en Current connection (with auth)
|
||||
*/
|
||||
conn: AuthConnection<TUser, TData>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Server Config
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 连接请求信息
|
||||
* @en Connection request info
|
||||
*/
|
||||
export interface ConnectionRequest {
|
||||
/**
|
||||
* @zh 请求 URL
|
||||
* @en Request URL
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* @zh 请求头
|
||||
* @en Request headers
|
||||
*/
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
|
||||
/**
|
||||
* @zh 客户端 IP
|
||||
* @en Client IP
|
||||
*/
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证服务器配置
|
||||
* @en Authentication server configuration
|
||||
*/
|
||||
export interface AuthServerConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication provider
|
||||
*/
|
||||
provider: IAuthProvider<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 从连接请求提取凭证
|
||||
* @en Extract credentials from connection request
|
||||
*/
|
||||
extractCredentials?: (req: ConnectionRequest) => unknown | Promise<unknown>;
|
||||
|
||||
/**
|
||||
* @zh 连接时自动认证
|
||||
* @en Auto authenticate on connection
|
||||
* @defaultValue true
|
||||
*/
|
||||
autoAuthOnConnect?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 未认证连接的宽限期(毫秒)
|
||||
* @en Grace period for unauthenticated connections (ms)
|
||||
* @defaultValue 30000
|
||||
*/
|
||||
authGracePeriod?: number;
|
||||
|
||||
/**
|
||||
* @zh 认证失败是否断开连接
|
||||
* @en Disconnect on auth failure
|
||||
* @defaultValue false
|
||||
*/
|
||||
disconnectOnAuthFailure?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 认证成功回调
|
||||
* @en Authentication success callback
|
||||
*/
|
||||
onAuthSuccess?: (conn: ServerConnection, user: TUser) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* @zh 认证失败回调
|
||||
* @en Authentication failure callback
|
||||
*/
|
||||
onAuthFailure?: (conn: ServerConnection, error: AuthResult<TUser>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Game Server
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 带认证的游戏服务器
|
||||
* @en Game server with authentication
|
||||
*/
|
||||
export interface AuthGameServer<TUser = unknown> extends GameServer {
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication provider
|
||||
*/
|
||||
readonly authProvider: IAuthProvider<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 手动认证连接
|
||||
* @en Manually authenticate connection
|
||||
*/
|
||||
authenticate(
|
||||
conn: ServerConnection,
|
||||
credentials: unknown
|
||||
): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 获取连接的认证上下文
|
||||
* @en Get auth context for connection
|
||||
*/
|
||||
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Room Config
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 带认证的房间配置
|
||||
* @en Auth room configuration
|
||||
*/
|
||||
export interface AuthRoomConfig {
|
||||
/**
|
||||
* @zh 是否要求认证才能加入
|
||||
* @en Require authentication to join
|
||||
* @defaultValue true
|
||||
*/
|
||||
requireAuth?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 允许的角色(空数组表示任意角色)
|
||||
* @en Allowed roles (empty array means any role)
|
||||
*/
|
||||
allowedRoles?: string[];
|
||||
|
||||
/**
|
||||
* @zh 角色检查模式
|
||||
* @en Role check mode
|
||||
* @defaultValue 'any'
|
||||
*/
|
||||
roleCheckMode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorator Options
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh requireAuth 装饰器选项
|
||||
* @en requireAuth decorator options
|
||||
*/
|
||||
export interface RequireAuthOptions {
|
||||
/**
|
||||
* @zh 认证失败时的错误消息
|
||||
* @en Error message on auth failure
|
||||
*/
|
||||
errorMessage?: string;
|
||||
|
||||
/**
|
||||
* @zh 是否允许访客
|
||||
* @en Allow guest access
|
||||
*/
|
||||
allowGuest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh requireRole 装饰器选项
|
||||
* @en requireRole decorator options
|
||||
*/
|
||||
export interface RequireRoleOptions extends RequireAuthOptions {
|
||||
/**
|
||||
* @zh 角色检查模式
|
||||
* @en Role check mode
|
||||
* @defaultValue 'any'
|
||||
*/
|
||||
mode?: 'any' | 'all';
|
||||
}
|
||||
348
packages/framework/server/src/ecs/ECSRoom.test.ts
Normal file
348
packages/framework/server/src/ecs/ECSRoom.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* @zh ECSRoom 集成测试
|
||||
* @en ECSRoom integration tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
|
||||
import {
|
||||
Core,
|
||||
Component,
|
||||
ECSComponent,
|
||||
sync,
|
||||
initChangeTracker,
|
||||
getSyncMetadata,
|
||||
registerSyncComponent,
|
||||
} from '@esengine/ecs-framework'
|
||||
import { createTestEnv, type TestEnvironment, wait } from '../testing/TestServer.js'
|
||||
import { ECSRoom } from './ECSRoom.js'
|
||||
import type { Player } from '../room/Player.js'
|
||||
import { onMessage } from '../room/decorators.js'
|
||||
|
||||
// ============================================================================
|
||||
// Test Components | 测试组件
|
||||
// ============================================================================
|
||||
|
||||
@ECSComponent('ECSRoomTest_PlayerComponent')
|
||||
class PlayerComponent extends Component {
|
||||
@sync('string') name: string = ''
|
||||
@sync('uint16') score: number = 0
|
||||
@sync('float32') x: number = 0
|
||||
@sync('float32') y: number = 0
|
||||
}
|
||||
|
||||
@ECSComponent('ECSRoomTest_HealthComponent')
|
||||
class HealthComponent extends Component {
|
||||
@sync('int32') current: number = 100
|
||||
@sync('int32') max: number = 100
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Room | 测试房间
|
||||
// ============================================================================
|
||||
|
||||
interface TestRoomState {
|
||||
gameStarted: boolean
|
||||
}
|
||||
|
||||
interface TestPlayerData {
|
||||
nickname: string
|
||||
}
|
||||
|
||||
class TestECSRoom extends ECSRoom<TestRoomState, TestPlayerData> {
|
||||
state: TestRoomState = {
|
||||
gameStarted: false,
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
// 可以在这里添加系统
|
||||
}
|
||||
|
||||
onJoin(player: Player<TestPlayerData>): void {
|
||||
const entity = this.createPlayerEntity(player.id)
|
||||
const comp = entity.addComponent(new PlayerComponent())
|
||||
comp.name = player.data.nickname || `Player_${player.id.slice(-4)}`
|
||||
comp.x = Math.random() * 100
|
||||
comp.y = Math.random() * 100
|
||||
|
||||
this.broadcast('PlayerJoined', {
|
||||
playerId: player.id,
|
||||
name: comp.name,
|
||||
})
|
||||
}
|
||||
|
||||
async onLeave(player: Player<TestPlayerData>, reason?: string): Promise<void> {
|
||||
await super.onLeave(player, reason)
|
||||
this.broadcast('PlayerLeft', { playerId: player.id })
|
||||
}
|
||||
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player<TestPlayerData>): void {
|
||||
const entity = this.getPlayerEntity(player.id)
|
||||
if (entity) {
|
||||
const comp = entity.getComponent(PlayerComponent)
|
||||
if (comp) {
|
||||
comp.x = data.x
|
||||
comp.y = data.y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@onMessage('AddScore')
|
||||
handleAddScore(data: { amount: number }, player: Player<TestPlayerData>): void {
|
||||
const entity = this.getPlayerEntity(player.id)
|
||||
if (entity) {
|
||||
const comp = entity.getComponent(PlayerComponent)
|
||||
if (comp) {
|
||||
comp.score += data.amount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@onMessage('Ping')
|
||||
handlePing(_data: unknown, player: Player<TestPlayerData>): void {
|
||||
player.send('Pong', { timestamp: Date.now() })
|
||||
}
|
||||
|
||||
getWorld() {
|
||||
return this.world
|
||||
}
|
||||
|
||||
getScene() {
|
||||
return this.scene
|
||||
}
|
||||
|
||||
getPlayerEntityCount(): number {
|
||||
return this.scene.entities.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites | 测试套件
|
||||
// ============================================================================
|
||||
|
||||
describe('ECSRoom Integration Tests', () => {
|
||||
let env: TestEnvironment
|
||||
|
||||
beforeAll(() => {
|
||||
Core.create()
|
||||
registerSyncComponent('ECSRoomTest_PlayerComponent', PlayerComponent)
|
||||
registerSyncComponent('ECSRoomTest_HealthComponent', HealthComponent)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Core.destroy()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnv({ tickRate: 20 })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Room Creation | 房间创建
|
||||
// ========================================================================
|
||||
|
||||
describe('Room Creation', () => {
|
||||
it('should create ECSRoom with World and Scene', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
expect(client.roomId).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have World managed by Core.worldManager', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
// 验证 World 正常创建(通过消息通信验证)
|
||||
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||
client.sendToRoom('Ping', {})
|
||||
const pong = await pongPromise
|
||||
|
||||
expect(pong.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Player Entity Management | 玩家实体管理
|
||||
// ========================================================================
|
||||
|
||||
describe('Player Entity Management', () => {
|
||||
it('should create player entity on join', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client1 = await env.createClient()
|
||||
const { roomId } = await client1.joinRoom('ecs-test')
|
||||
|
||||
// 等待第二个玩家加入时收到广播
|
||||
const joinPromise = client1.waitForRoomMessage<{ playerId: string; name: string }>(
|
||||
'PlayerJoined'
|
||||
)
|
||||
|
||||
const client2 = await env.createClient()
|
||||
await client2.joinRoomById(roomId)
|
||||
|
||||
const joinMsg = await joinPromise
|
||||
expect(joinMsg.playerId).toBe(client2.playerId)
|
||||
expect(joinMsg.name).toContain('Player_')
|
||||
})
|
||||
|
||||
it('should destroy player entity on leave', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client1 = await env.createClient()
|
||||
const { roomId } = await client1.joinRoom('ecs-test')
|
||||
|
||||
const client2 = await env.createClient()
|
||||
await client2.joinRoomById(roomId)
|
||||
|
||||
const leavePromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerLeft')
|
||||
|
||||
await client2.leaveRoom()
|
||||
|
||||
const leaveMsg = await leavePromise
|
||||
expect(leaveMsg.playerId).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Component Sync | 组件同步
|
||||
// ========================================================================
|
||||
|
||||
describe('Component State Updates', () => {
|
||||
it('should update component via message handler', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
client.sendToRoom('Move', { x: 100, y: 200 })
|
||||
|
||||
// 等待处理
|
||||
await wait(50)
|
||||
|
||||
// 验证 Ping/Pong 仍能工作(房间仍活跃)
|
||||
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||
client.sendToRoom('Ping', {})
|
||||
const pong = await pongPromise
|
||||
|
||||
expect(pong.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle AddScore message', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
client.sendToRoom('AddScore', { amount: 50 })
|
||||
client.sendToRoom('AddScore', { amount: 25 })
|
||||
|
||||
await wait(50)
|
||||
|
||||
// 确认房间仍然活跃
|
||||
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||
client.sendToRoom('Ping', {})
|
||||
await pongPromise
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Sync Broadcast | 同步广播
|
||||
// ========================================================================
|
||||
|
||||
describe('State Sync Broadcast', () => {
|
||||
it('should receive $sync messages when enabled', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
// 触发状态变更
|
||||
client.sendToRoom('Move', { x: 50, y: 75 })
|
||||
|
||||
// 等待 tick 处理
|
||||
await wait(200)
|
||||
|
||||
// 检查是否收到 $sync 消息
|
||||
const hasSync = client.hasReceivedMessage('RoomMessage')
|
||||
expect(hasSync).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Multi-player Sync | 多玩家同步
|
||||
// ========================================================================
|
||||
|
||||
describe('Multi-player Scenarios', () => {
|
||||
it('should handle multiple players in same room', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client1 = await env.createClient()
|
||||
const { roomId } = await client1.joinRoom('ecs-test')
|
||||
|
||||
const client2 = await env.createClient()
|
||||
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
|
||||
await client2.joinRoomById(roomId)
|
||||
|
||||
const joinMsg = await joinPromise
|
||||
expect(joinMsg.playerId).toBe(client2.playerId)
|
||||
})
|
||||
|
||||
it('should broadcast to all players on state change', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client1 = await env.createClient()
|
||||
const { roomId } = await client1.joinRoom('ecs-test')
|
||||
|
||||
const client2 = await env.createClient()
|
||||
|
||||
// client1 等待收到 client2 加入的广播
|
||||
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
|
||||
|
||||
await client2.joinRoomById(roomId)
|
||||
|
||||
const joinMsg = await joinPromise
|
||||
expect(joinMsg.playerId).toBe(client2.playerId)
|
||||
|
||||
// 验证每个客户端都能独立通信
|
||||
const pong1Promise = client1.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||
client1.sendToRoom('Ping', {})
|
||||
const pong1 = await pong1Promise
|
||||
expect(pong1.timestamp).toBeGreaterThan(0)
|
||||
|
||||
const pong2Promise = client2.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||
client2.sendToRoom('Ping', {})
|
||||
const pong2 = await pong2Promise
|
||||
expect(pong2.timestamp).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================================================
|
||||
// Cleanup | 清理
|
||||
// ========================================================================
|
||||
|
||||
describe('Room Cleanup', () => {
|
||||
it('should cleanup World on dispose', async () => {
|
||||
env.server.define('ecs-test', TestECSRoom)
|
||||
|
||||
const client = await env.createClient()
|
||||
await client.joinRoom('ecs-test')
|
||||
|
||||
await client.leaveRoom()
|
||||
|
||||
// 等待自动销毁
|
||||
await wait(100)
|
||||
|
||||
// 房间应该已销毁
|
||||
expect(client.roomId).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
345
packages/framework/server/src/ecs/ECSRoom.ts
Normal file
345
packages/framework/server/src/ecs/ECSRoom.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @zh ECS 房间基类
|
||||
* @en ECS Room base class
|
||||
*/
|
||||
|
||||
import {
|
||||
Core,
|
||||
Scene,
|
||||
World,
|
||||
Entity,
|
||||
EntitySystem,
|
||||
type Component,
|
||||
// Sync
|
||||
SyncOperation,
|
||||
SYNC_METADATA,
|
||||
CHANGE_TRACKER,
|
||||
type SyncMetadata,
|
||||
type ChangeTracker,
|
||||
encodeSnapshot,
|
||||
encodeSpawn,
|
||||
encodeDespawn,
|
||||
initChangeTracker,
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
import { Room, type RoomOptions } from '../room/Room.js';
|
||||
import type { Player } from '../room/Player.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh ECS 房间配置
|
||||
* @en ECS room configuration
|
||||
*/
|
||||
export interface ECSRoomConfig {
|
||||
/**
|
||||
* @zh 状态同步间隔(毫秒)
|
||||
* @en State sync interval in milliseconds
|
||||
*/
|
||||
syncInterval: number;
|
||||
|
||||
/**
|
||||
* @zh 是否启用增量同步
|
||||
* @en Whether to enable delta sync
|
||||
*/
|
||||
enableDeltaSync: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ECS_CONFIG: ECSRoomConfig = {
|
||||
syncInterval: 50, // 20 Hz
|
||||
enableDeltaSync: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 网络实体标识组件
|
||||
* @en Network entity identity component
|
||||
*/
|
||||
const NETWORK_ENTITY_OWNER = Symbol('NetworkEntityOwner');
|
||||
|
||||
// =============================================================================
|
||||
// ECSRoom | ECS 房间
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh ECS 房间基类,带有 ECS World 支持和自动状态同步
|
||||
* @en ECS Room base class with ECS World support and automatic state synchronization
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 服务端启动
|
||||
* Core.create();
|
||||
* setInterval(() => Core.update(1/60), 16);
|
||||
*
|
||||
* // 定义房间
|
||||
* class GameRoom extends ECSRoom {
|
||||
* onCreate() {
|
||||
* this.addSystem(new PhysicsSystem());
|
||||
* }
|
||||
*
|
||||
* onJoin(player: Player) {
|
||||
* const entity = this.createPlayerEntity(player.id);
|
||||
* entity.addComponent(new PlayerComponent());
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class ECSRoom<TState = any, TPlayerData = Record<string, unknown>> extends Room<TState, TPlayerData> {
|
||||
/**
|
||||
* @zh ECS World(由 Core.worldManager 管理)
|
||||
* @en ECS World (managed by Core.worldManager)
|
||||
*/
|
||||
protected readonly world: World;
|
||||
|
||||
/**
|
||||
* @zh World 在 WorldManager 中的 ID
|
||||
* @en World ID in WorldManager
|
||||
*/
|
||||
protected readonly worldId: string;
|
||||
|
||||
/**
|
||||
* @zh 房间的主场景
|
||||
* @en Room's main scene
|
||||
*/
|
||||
protected readonly scene: Scene;
|
||||
|
||||
/**
|
||||
* @zh ECS 配置
|
||||
* @en ECS configuration
|
||||
*/
|
||||
protected readonly ecsConfig: ECSRoomConfig;
|
||||
|
||||
/**
|
||||
* @zh 玩家 ID 到实体的映射
|
||||
* @en Player ID to Entity mapping
|
||||
*/
|
||||
private readonly _playerEntities: Map<string, Entity> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 上次同步时间
|
||||
* @en Last sync time
|
||||
*/
|
||||
private _lastSyncTime: number = 0;
|
||||
|
||||
constructor(ecsConfig?: Partial<ECSRoomConfig>) {
|
||||
super();
|
||||
this.ecsConfig = { ...DEFAULT_ECS_CONFIG, ...ecsConfig };
|
||||
|
||||
this.worldId = `room_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.world = Core.worldManager.createWorld(this.worldId);
|
||||
this.scene = this.world.createScene('game');
|
||||
this.world.setSceneActive('game', true);
|
||||
this.world.start();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scene Management | 场景管理
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加系统到场景
|
||||
* @en Add system to scene
|
||||
*/
|
||||
protected addSystem(system: EntitySystem): void {
|
||||
this.scene.addSystem(system);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建实体
|
||||
* @en Create entity
|
||||
*/
|
||||
protected createEntity(name?: string): Entity {
|
||||
return this.scene.createEntity(name ?? `entity_${Date.now()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 为玩家创建实体
|
||||
* @en Create entity for player
|
||||
*
|
||||
* @param playerId - @zh 玩家 ID @en Player ID
|
||||
* @param name - @zh 实体名称 @en Entity name
|
||||
* @returns @zh 创建的实体 @en Created entity
|
||||
*/
|
||||
protected createPlayerEntity(playerId: string, name?: string): Entity {
|
||||
const entityName = name ?? `player_${playerId}`;
|
||||
const entity = this.scene.createEntity(entityName);
|
||||
(entity as any)[NETWORK_ENTITY_OWNER] = playerId;
|
||||
this._playerEntities.set(playerId, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家的实体
|
||||
* @en Get player's entity
|
||||
*/
|
||||
protected getPlayerEntity(playerId: string): Entity | undefined {
|
||||
return this._playerEntities.get(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 销毁玩家的实体
|
||||
* @en Destroy player's entity
|
||||
*/
|
||||
protected destroyPlayerEntity(playerId: string): void {
|
||||
const entity = this._playerEntities.get(playerId);
|
||||
if (entity) {
|
||||
const despawnData = encodeDespawn(entity.id);
|
||||
this.broadcastBinary(despawnData);
|
||||
entity.destroy();
|
||||
this._playerEntities.delete(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State Sync | 状态同步
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 广播二进制数据
|
||||
* @en Broadcast binary data
|
||||
*/
|
||||
protected broadcastBinary(data: Uint8Array): void {
|
||||
for (const player of this.players) {
|
||||
this.sendBinary(player, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 发送二进制数据给指定玩家
|
||||
* @en Send binary data to specific player
|
||||
*/
|
||||
protected sendBinary(player: Player<TPlayerData>, data: Uint8Array): void {
|
||||
player.send('$sync', { data: Array.from(data) });
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 发送完整状态给玩家(用于玩家刚加入时)
|
||||
* @en Send full state to player (for when player just joined)
|
||||
*/
|
||||
protected sendFullState(player: Player<TPlayerData>): void {
|
||||
const entities = this._getSyncEntities();
|
||||
if (entities.length === 0) return;
|
||||
|
||||
for (const entity of entities) {
|
||||
this._initComponentTrackers(entity);
|
||||
}
|
||||
|
||||
const data = encodeSnapshot(entities, SyncOperation.FULL);
|
||||
this.sendBinary(player, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 广播实体生成
|
||||
* @en Broadcast entity spawn
|
||||
*/
|
||||
protected broadcastSpawn(entity: Entity, prefabType?: string): void {
|
||||
this._initComponentTrackers(entity);
|
||||
const data = encodeSpawn(entity, prefabType);
|
||||
this.broadcastBinary(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 广播增量状态更新
|
||||
* @en Broadcast delta state update
|
||||
*/
|
||||
protected broadcastDelta(): void {
|
||||
const entities = this._getSyncEntities();
|
||||
const changedEntities = entities.filter(entity => this._hasChanges(entity));
|
||||
|
||||
if (changedEntities.length === 0) return;
|
||||
|
||||
const data = encodeSnapshot(changedEntities, SyncOperation.DELTA);
|
||||
this.broadcastBinary(data);
|
||||
this._clearChangeTrackers(changedEntities);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle Overrides | 生命周期重载
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 游戏循环,处理状态同步
|
||||
* @en Game tick, handles state sync
|
||||
*/
|
||||
override onTick(_dt: number): void {
|
||||
if (this.ecsConfig.enableDeltaSync) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastSyncTime >= this.ecsConfig.syncInterval) {
|
||||
this._lastSyncTime = now;
|
||||
this.broadcastDelta();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家离开时自动销毁其实体
|
||||
* @en Auto destroy player entity when leaving
|
||||
*/
|
||||
override async onLeave(player: Player<TPlayerData>, reason?: string): Promise<void> {
|
||||
this.destroyPlayerEntity(player.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 房间销毁时从 WorldManager 移除 World
|
||||
* @en Remove World from WorldManager when room is disposed
|
||||
*/
|
||||
override onDispose(): void {
|
||||
this._playerEntities.clear();
|
||||
Core.worldManager.removeWorld(this.worldId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal | 内部方法
|
||||
// =========================================================================
|
||||
|
||||
private _getSyncEntities(): Entity[] {
|
||||
const entities: Entity[] = [];
|
||||
for (const entity of this.scene.entities.buffer) {
|
||||
if (this._hasSyncComponents(entity)) {
|
||||
entities.push(entity);
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
private _hasSyncComponents(entity: Entity): boolean {
|
||||
for (const component of entity.components) {
|
||||
const metadata: SyncMetadata | undefined = (component.constructor as any)[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _hasChanges(entity: Entity): boolean {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker?.hasChanges()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _initComponentTrackers(entity: Entity): void {
|
||||
for (const component of entity.components) {
|
||||
const metadata: SyncMetadata | undefined = (component.constructor as any)[SYNC_METADATA];
|
||||
if (metadata && metadata.fields.length > 0) {
|
||||
initChangeTracker(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _clearChangeTrackers(entities: Entity[]): void {
|
||||
for (const entity of entities) {
|
||||
for (const component of entity.components) {
|
||||
const tracker = (component as any)[CHANGE_TRACKER] as ChangeTracker | undefined;
|
||||
if (tracker) {
|
||||
tracker.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
packages/framework/server/src/ecs/index.ts
Normal file
62
packages/framework/server/src/ecs/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @zh @esengine/server ECS 集成模块
|
||||
* @en @esengine/server ECS integration module
|
||||
*
|
||||
* @zh 提供带 ECS World 的房间类,支持基于 @sync 装饰器的自动状态同步
|
||||
* @en Provides Room class with ECS World, supports automatic state sync based on @sync decorator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ECSRoom } from '@esengine/server/ecs';
|
||||
* import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
|
||||
*
|
||||
* @ECSComponent('Player')
|
||||
* class PlayerComponent extends Component {
|
||||
* @sync("string") name: string = "";
|
||||
* @sync("uint16") score: number = 0;
|
||||
* }
|
||||
*
|
||||
* class GameRoom extends ECSRoom {
|
||||
* onCreate() {
|
||||
* this.addSystem(new MovementSystem());
|
||||
* }
|
||||
*
|
||||
* onJoin(player: Player) {
|
||||
* const entity = this.createPlayerEntity(player.id);
|
||||
* entity.addComponent(new PlayerComponent());
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { ECSRoom } from './ECSRoom.js';
|
||||
export type { ECSRoomConfig } from './ECSRoom.js';
|
||||
|
||||
// Re-export Player for convenience
|
||||
export { Player, type IPlayer } from '../room/Player.js';
|
||||
|
||||
// Re-export commonly used ECS types for convenience
|
||||
export type {
|
||||
Entity,
|
||||
Component,
|
||||
EntitySystem,
|
||||
Scene,
|
||||
World,
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// Re-export sync types
|
||||
export {
|
||||
sync,
|
||||
getSyncMetadata,
|
||||
hasSyncFields,
|
||||
initChangeTracker,
|
||||
clearChanges,
|
||||
hasChanges,
|
||||
SyncOperation,
|
||||
type SyncType,
|
||||
type SyncFieldMetadata,
|
||||
type SyncMetadata,
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// Re-export room decorators
|
||||
export { onMessage } from '../room/decorators.js';
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RateLimitContext } from '../context';
|
||||
import { TokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { FixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('RateLimitContext', () => {
|
||||
let globalStrategy: TokenBucketStrategy;
|
||||
let context: RateLimitContext;
|
||||
|
||||
beforeEach(() => {
|
||||
globalStrategy = new TokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
context = new RateLimitContext('player-123', globalStrategy);
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('should check without consuming', () => {
|
||||
const result1 = context.check();
|
||||
const result2 = context.check();
|
||||
|
||||
expect(result1.remaining).toBe(result2.remaining);
|
||||
});
|
||||
|
||||
it('should use global strategy by default', () => {
|
||||
const result = context.check();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should consume from global strategy', () => {
|
||||
const result = context.consume();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should track consecutive limits', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reset consecutive count on success', () => {
|
||||
// Consume all 20 tokens plus some more to trigger rate limiting
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
// After consuming 25 tokens (20 capacity), 5 should be rate limited
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.reset();
|
||||
const result = context.consume();
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset global strategy', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
context.reset();
|
||||
|
||||
const status = context.check();
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
|
||||
it('should reset specific message type', () => {
|
||||
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
context.setMessageStrategy('Trade', msgStrategy);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
context.consume('Trade');
|
||||
}
|
||||
|
||||
context.reset('Trade');
|
||||
|
||||
const status = context.check('Trade');
|
||||
expect(status.remaining).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message strategies', () => {
|
||||
it('should use message-specific strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
const result1 = context.consume('Trade');
|
||||
expect(result1.allowed).toBe(true);
|
||||
|
||||
const result2 = context.consume('Trade');
|
||||
expect(result2.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to global strategy for unknown types', () => {
|
||||
const result = context.consume('UnknownType');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should check if message strategy exists', () => {
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove message strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
context.removeMessageStrategy('Trade');
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetConsecutiveCount', () => {
|
||||
it('should reset consecutive limit count', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.resetConsecutiveCount();
|
||||
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from '../decorators/rateLimit';
|
||||
|
||||
describe('rateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
|
||||
handleTrade() {
|
||||
return 'trade';
|
||||
}
|
||||
|
||||
@rateLimitMessage('Move', { cost: 2 })
|
||||
handleMove() {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
undecorated() {
|
||||
return 'undecorated';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store rate limit metadata on target', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should store config in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata?.config?.messagesPerSecond).toBe(1);
|
||||
expect(metadata?.config?.burstSize).toBe(2);
|
||||
});
|
||||
|
||||
it('should store cost in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
|
||||
expect(metadata?.config?.cost).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered message types', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleTrade()).toBe('trade');
|
||||
expect(instance.handleMove()).toBe('move');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
handleHeartbeat() {
|
||||
return 'heartbeat';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('Ping')
|
||||
handlePing() {
|
||||
return 'ping';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should mark message as exempt', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
expect(metadata?.exempt).toBe(true);
|
||||
expect(metadata?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should store for multiple messages', () => {
|
||||
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
|
||||
|
||||
expect(heartbeatMeta?.exempt).toBe(true);
|
||||
expect(pingMeta?.exempt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleHeartbeat()).toBe('heartbeat');
|
||||
expect(instance.handlePing()).toBe('ping');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined decorators', () => {
|
||||
class CombinedTestClass {
|
||||
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
|
||||
handleSlow() {
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('FastAction')
|
||||
handleFast() {
|
||||
return 'fast';
|
||||
}
|
||||
|
||||
@rateLimitMessage('ExpensiveAction', { cost: 10 })
|
||||
handleExpensive() {
|
||||
return 'expensive';
|
||||
}
|
||||
}
|
||||
|
||||
it('should handle multiple different decorators', () => {
|
||||
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
|
||||
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
|
||||
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
|
||||
|
||||
expect(slowMeta?.enabled).toBe(true);
|
||||
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
|
||||
|
||||
expect(fastMeta?.exempt).toBe(true);
|
||||
expect(fastMeta?.enabled).toBe(false);
|
||||
|
||||
expect(expensiveMeta?.enabled).toBe(true);
|
||||
expect(expensiveMeta?.config?.cost).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RATE_LIMIT_METADATA_KEY', () => {
|
||||
it('should be a symbol', () => {
|
||||
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('should be used for metadata storage', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Test', {})
|
||||
handleTest() {}
|
||||
}
|
||||
|
||||
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||
expect(metadataMap).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof rateLimit).toBe('function');
|
||||
expect(typeof rateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept config', () => {
|
||||
class TestClass {
|
||||
@rateLimit({ messagesPerSecond: 5 })
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof noRateLimit).toBe('function');
|
||||
expect(typeof noRateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should work as decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimit()
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Room } from '../../room/Room';
|
||||
import { Player } from '../../room/Player';
|
||||
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
|
||||
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
|
||||
import { onMessage } from '../../room/decorators';
|
||||
|
||||
describe('withRateLimit mixin', () => {
|
||||
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
|
||||
|
||||
beforeEach(() => {
|
||||
RateLimitedRoom = withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should create a rate limited room class', () => {
|
||||
expect(RateLimitedRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have rateLimitStrategy property', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy selection', () => {
|
||||
it('should use token-bucket by default', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('should use sliding-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('should use fixed-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('should use default values', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom messagesPerSecond', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom burstSize', () => {
|
||||
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should clean up on dispose', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
room._init({
|
||||
id: 'test-room',
|
||||
sendFn: vi.fn(),
|
||||
broadcastFn: vi.fn(),
|
||||
disposeFn: vi.fn()
|
||||
});
|
||||
|
||||
expect(() => room.dispose()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRateLimit with auth', () => {
|
||||
it('should be composable with other mixins', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerRateLimitContext', () => {
|
||||
it('should return null for player without context', () => {
|
||||
const mockPlayer = {
|
||||
id: 'player-1',
|
||||
roomId: 'room-1',
|
||||
data: {},
|
||||
send: vi.fn(),
|
||||
leave: vi.fn()
|
||||
} as unknown as Player;
|
||||
|
||||
const context = getPlayerRateLimitContext(mockPlayer);
|
||||
expect(context).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorator metadata', () => {
|
||||
it('rateLimitMessage should set metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('noRateLimitMessage should set exempt metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
|
||||
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('TokenBucketStrategy', () => {
|
||||
let strategy: TokenBucketStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createTokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests when tokens available', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should consume multiple tokens', () => {
|
||||
const result = strategy.consume('user-1', 5);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(15);
|
||||
});
|
||||
|
||||
it('should deny when not enough tokens', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should refill tokens over time', async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different keys independently', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result1 = strategy.consume('user-1');
|
||||
const result2 = strategy.consume('user-2');
|
||||
|
||||
expect(result1.allowed).toBe(false);
|
||||
expect(result2.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(20);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not consume tokens', () => {
|
||||
strategy.getStatus('user-1');
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset key to full capacity', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up full buckets', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlidingWindowStrategy', () => {
|
||||
let strategy: SlidingWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createSlidingWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow after window expires', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear timestamps', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FixedWindowStrategy', () => {
|
||||
let strategy: FixedWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createFixedWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should reset at window boundary', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset count', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old windows', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory functions', () => {
|
||||
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
|
||||
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
|
||||
expect(strategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
|
||||
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
|
||||
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
146
packages/framework/server/src/ratelimit/context.ts
Normal file
146
packages/framework/server/src/ratelimit/context.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*/
|
||||
|
||||
import type {
|
||||
IRateLimitContext,
|
||||
IRateLimitStrategy,
|
||||
RateLimitResult,
|
||||
MessageRateLimitConfig
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*
|
||||
* @zh 管理单个玩家的速率限制状态,支持全局限制和按消息类型限制
|
||||
* @en Manages rate limit status for a single player, supports global and per-message-type limits
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const context = new RateLimitContext('player-123', globalStrategy);
|
||||
*
|
||||
* // Check global rate limit
|
||||
* const result = context.consume();
|
||||
*
|
||||
* // Check per-message rate limit
|
||||
* const tradeResult = context.consume('Trade', 1);
|
||||
* ```
|
||||
*/
|
||||
export class RateLimitContext implements IRateLimitContext {
|
||||
private _key: string;
|
||||
private _globalStrategy: IRateLimitStrategy;
|
||||
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
|
||||
private _consecutiveLimitCount: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建速率限制上下文
|
||||
* @en Create rate limit context
|
||||
*
|
||||
* @param key - @zh 限流键(通常是玩家ID)@en Rate limit key (usually player ID)
|
||||
* @param globalStrategy - @zh 全局限流策略 @en Global rate limit strategy
|
||||
*/
|
||||
constructor(key: string, globalStrategy: IRateLimitStrategy) {
|
||||
this._key = key;
|
||||
this._globalStrategy = globalStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取连续被限流次数
|
||||
* @en Get consecutive limit count
|
||||
*/
|
||||
get consecutiveLimitCount(): number {
|
||||
return this._consecutiveLimitCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否允许(不消费)
|
||||
* @en Check if allowed (without consuming)
|
||||
*/
|
||||
check(messageType?: string): RateLimitResult {
|
||||
if (messageType && this._messageStrategies.has(messageType)) {
|
||||
return this._messageStrategies.get(messageType)!.getStatus(this._key);
|
||||
}
|
||||
|
||||
return this._globalStrategy.getStatus(this._key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消费配额
|
||||
* @en Consume quota
|
||||
*/
|
||||
consume(messageType?: string, cost: number = 1): RateLimitResult {
|
||||
let result: RateLimitResult;
|
||||
|
||||
if (messageType && this._messageStrategies.has(messageType)) {
|
||||
result = this._messageStrategies.get(messageType)!.consume(this._key, cost);
|
||||
} else {
|
||||
result = this._globalStrategy.consume(this._key, cost);
|
||||
}
|
||||
|
||||
if (result.allowed) {
|
||||
this._consecutiveLimitCount = 0;
|
||||
} else {
|
||||
this._consecutiveLimitCount++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置限流状态
|
||||
* @en Reset rate limit status
|
||||
*/
|
||||
reset(messageType?: string): void {
|
||||
if (messageType) {
|
||||
if (this._messageStrategies.has(messageType)) {
|
||||
this._messageStrategies.get(messageType)!.reset(this._key);
|
||||
}
|
||||
} else {
|
||||
this._globalStrategy.reset(this._key);
|
||||
for (const strategy of this._messageStrategies.values()) {
|
||||
strategy.reset(this._key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置连续限流计数
|
||||
* @en Reset consecutive limit count
|
||||
*/
|
||||
resetConsecutiveCount(): void {
|
||||
this._consecutiveLimitCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 为特定消息类型设置独立的限流策略
|
||||
* @en Set independent rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @param strategy - @zh 限流策略 @en Rate limit strategy
|
||||
*/
|
||||
setMessageStrategy(messageType: string, strategy: IRateLimitStrategy): void {
|
||||
this._messageStrategies.set(messageType, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除特定消息类型的限流策略
|
||||
* @en Remove rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
*/
|
||||
removeMessageStrategy(messageType: string): void {
|
||||
this._messageStrategies.delete(messageType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有特定消息类型的限流策略
|
||||
* @en Check if has rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
*/
|
||||
hasMessageStrategy(messageType: string): boolean {
|
||||
return this._messageStrategies.has(messageType);
|
||||
}
|
||||
}
|
||||
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorators
|
||||
*/
|
||||
|
||||
export {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from './rateLimit.js';
|
||||
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal file
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorators
|
||||
*/
|
||||
|
||||
import type { MessageRateLimitConfig, RateLimitMetadata } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制元数据存储键
|
||||
* @en Rate limit metadata storage key
|
||||
*/
|
||||
export const RATE_LIMIT_METADATA_KEY = Symbol('rateLimitMetadata');
|
||||
|
||||
/**
|
||||
* @zh 获取速率限制元数据
|
||||
* @en Get rate limit metadata
|
||||
*
|
||||
* @param target - @zh 目标对象 @en Target object
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @returns @zh 元数据 @en Metadata
|
||||
*/
|
||||
export function getRateLimitMetadata(target: any, messageType: string): RateLimitMetadata | undefined {
|
||||
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata> | undefined;
|
||||
return metadataMap?.get(messageType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置速率限制元数据
|
||||
* @en Set rate limit metadata
|
||||
*
|
||||
* @param target - @zh 目标对象 @en Target object
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @param metadata - @zh 元数据 @en Metadata
|
||||
*/
|
||||
function setRateLimitMetadata(target: any, messageType: string, metadata: RateLimitMetadata): void {
|
||||
if (!target[RATE_LIMIT_METADATA_KEY]) {
|
||||
target[RATE_LIMIT_METADATA_KEY] = new Map<string, RateLimitMetadata>();
|
||||
}
|
||||
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata>;
|
||||
const existing = metadataMap.get(messageType) ?? { enabled: true };
|
||||
metadataMap.set(messageType, { ...existing, ...metadata });
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从方法获取消息类型
|
||||
* @en Get message type from method
|
||||
*
|
||||
* @zh 通过查找 onMessage 装饰器设置的元数据来获取消息类型
|
||||
* @en Gets message type by looking up metadata set by onMessage decorator
|
||||
*/
|
||||
function getMessageTypeFromMethod(target: any, methodName: string): string | undefined {
|
||||
const messageHandlers = Symbol.for('messageHandlers');
|
||||
|
||||
for (const sym of Object.getOwnPropertySymbols(target.constructor)) {
|
||||
const desc = Object.getOwnPropertyDescriptor(target.constructor, sym);
|
||||
if (desc?.value && Array.isArray(desc.value)) {
|
||||
for (const handler of desc.value) {
|
||||
if (handler.method === methodName) {
|
||||
return handler.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = target.constructor[Symbol.for('messageHandlers')] as { type: string; method: string }[] | undefined;
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
if (handler.method === methodName) {
|
||||
return handler.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorator
|
||||
*
|
||||
* @zh 为消息处理器设置独立的速率限制配置
|
||||
* @en Set independent rate limit configuration for message handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // This message has stricter rate limit
|
||||
* }
|
||||
*
|
||||
* @rateLimit({ cost: 5 })
|
||||
* @onMessage('ExpensiveAction')
|
||||
* handleExpensiveAction(data: any, player: Player) {
|
||||
* // This message consumes 5 tokens
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const msgType = getMessageTypeFromMethod(target, methodName);
|
||||
if (msgType) {
|
||||
setRateLimitMetadata(target, msgType, {
|
||||
enabled: true,
|
||||
config
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metadata: RateLimitMetadata = {
|
||||
enabled: true,
|
||||
config
|
||||
};
|
||||
|
||||
if (!target.hasOwnProperty(RATE_LIMIT_METADATA_KEY)) {
|
||||
Object.defineProperty(target, RATE_LIMIT_METADATA_KEY, {
|
||||
value: new Map<string, RateLimitMetadata>(),
|
||||
writable: false,
|
||||
enumerable: false
|
||||
});
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 豁免速率限制装饰器
|
||||
* @en Exempt from rate limit decorator
|
||||
*
|
||||
* @zh 标记消息处理器不受速率限制
|
||||
* @en Mark message handler as exempt from rate limit
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @noRateLimit()
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // This message is not rate limited
|
||||
* }
|
||||
*
|
||||
* @noRateLimit()
|
||||
* @onMessage('Ping')
|
||||
* handlePing(data: any, player: Player) {
|
||||
* player.send('Pong', {});
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function noRateLimit(): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const msgType = getMessageTypeFromMethod(target, methodName);
|
||||
if (msgType) {
|
||||
setRateLimitMetadata(target, msgType, {
|
||||
enabled: false,
|
||||
exempt: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制消息装饰器(直接指定消息类型)
|
||||
* @en Rate limit message decorator (directly specify message type)
|
||||
*
|
||||
* @zh 当无法自动获取消息类型时使用此装饰器
|
||||
* @en Use this decorator when message type cannot be obtained automatically
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // Explicitly rate limited
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function rateLimitMessage(
|
||||
messageType: string,
|
||||
config?: MessageRateLimitConfig
|
||||
): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
setRateLimitMetadata(target, messageType, {
|
||||
enabled: true,
|
||||
config
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 豁免速率限制消息装饰器(直接指定消息类型)
|
||||
* @en Exempt rate limit message decorator (directly specify message type)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @noRateLimitMessage('Heartbeat')
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // Explicitly exempted
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function noRateLimitMessage(messageType: string): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
setRateLimitMetadata(target, messageType, {
|
||||
enabled: false,
|
||||
exempt: true
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
91
packages/framework/server/src/ratelimit/index.ts
Normal file
91
packages/framework/server/src/ratelimit/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @zh 速率限制模块
|
||||
* @en Rate limit module
|
||||
*
|
||||
* @zh 提供可插拔的速率限制系统,支持多种限流算法
|
||||
* @en Provides pluggable rate limit system with multiple algorithms
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import {
|
||||
* withRateLimit,
|
||||
* rateLimit,
|
||||
* noRateLimit
|
||||
* } from '@esengine/server/ratelimit';
|
||||
*
|
||||
* class GameRoom extends withRateLimit(Room, {
|
||||
* messagesPerSecond: 10,
|
||||
* burstSize: 20,
|
||||
* strategy: 'token-bucket',
|
||||
* onLimited: (player, type, result) => {
|
||||
* player.send('Error', {
|
||||
* code: 'RATE_LIMITED',
|
||||
* retryAfter: result.retryAfter
|
||||
* });
|
||||
* }
|
||||
* }) {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // Protected by default rate limit
|
||||
* }
|
||||
*
|
||||
* @rateLimit({ messagesPerSecond: 1 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // Stricter rate limit for trading
|
||||
* }
|
||||
*
|
||||
* @noRateLimit()
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // No rate limit for heartbeat
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RateLimitResult,
|
||||
IRateLimitStrategy,
|
||||
RateLimitStrategyType,
|
||||
RateLimitConfig,
|
||||
MessageRateLimitConfig,
|
||||
RateLimitMetadata,
|
||||
IRateLimitContext,
|
||||
RateLimitedRoom,
|
||||
StrategyConfig
|
||||
} from './types.js';
|
||||
|
||||
// Strategies
|
||||
export {
|
||||
TokenBucketStrategy,
|
||||
createTokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
createSlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createFixedWindowStrategy
|
||||
} from './strategies/index.js';
|
||||
|
||||
// Context
|
||||
export { RateLimitContext } from './context.js';
|
||||
|
||||
// Mixin
|
||||
export {
|
||||
withRateLimit,
|
||||
getPlayerRateLimitContext,
|
||||
type RateLimitedPlayer,
|
||||
type IRateLimitRoom,
|
||||
type RateLimitRoomClass
|
||||
} from './mixin/index.js';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from './decorators/index.js';
|
||||
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @zh 速率限制 Mixin
|
||||
* @en Rate limit mixin
|
||||
*/
|
||||
|
||||
export {
|
||||
withRateLimit,
|
||||
getPlayerRateLimitContext,
|
||||
type RateLimitedPlayer,
|
||||
type IRateLimitRoom,
|
||||
type RateLimitRoomClass
|
||||
} from './withRateLimit.js';
|
||||
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal file
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @zh 房间速率限制 Mixin
|
||||
* @en Room rate limit mixin
|
||||
*/
|
||||
|
||||
import type { Player, Room } from '../../room/index.js';
|
||||
import { RateLimitContext } from '../context.js';
|
||||
import { getRateLimitMetadata, RATE_LIMIT_METADATA_KEY } from '../decorators/rateLimit.js';
|
||||
import { FixedWindowStrategy } from '../strategies/FixedWindow.js';
|
||||
import { SlidingWindowStrategy } from '../strategies/SlidingWindow.js';
|
||||
import { TokenBucketStrategy } from '../strategies/TokenBucket.js';
|
||||
import type {
|
||||
IRateLimitContext,
|
||||
IRateLimitStrategy,
|
||||
RateLimitConfig,
|
||||
RateLimitMetadata,
|
||||
RateLimitResult
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 玩家速率限制上下文存储
|
||||
* @en Player rate limit context storage
|
||||
*/
|
||||
const PLAYER_RATE_LIMIT_CONTEXT = Symbol('playerRateLimitContext');
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的玩家
|
||||
* @en Player with rate limit
|
||||
*/
|
||||
export interface RateLimitedPlayer<TData = Record<string, unknown>> extends Player<TData> {
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*/
|
||||
readonly rateLimit: IRateLimitContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的房间接口
|
||||
* @en Room with rate limit interface
|
||||
*/
|
||||
export interface IRateLimitRoom {
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null;
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
readonly rateLimitStrategy: IRateLimitStrategy;
|
||||
|
||||
/**
|
||||
* @zh 速率限制钩子(被限流时调用)
|
||||
* @en Rate limit hook (called when rate limited)
|
||||
*/
|
||||
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制房间构造器类型
|
||||
* @en Rate limit room constructor type
|
||||
*/
|
||||
export type RateLimitRoomClass = new (...args: any[]) => Room & IRateLimitRoom;
|
||||
|
||||
/**
|
||||
* @zh 创建策略实例
|
||||
* @en Create strategy instance
|
||||
*/
|
||||
function createStrategy(config: RateLimitConfig): IRateLimitStrategy {
|
||||
const rate = config.messagesPerSecond ?? 10;
|
||||
const capacity = config.burstSize ?? rate * 2;
|
||||
|
||||
switch (config.strategy) {
|
||||
case 'sliding-window':
|
||||
return new SlidingWindowStrategy({ rate, capacity });
|
||||
case 'fixed-window':
|
||||
return new FixedWindowStrategy({ rate, capacity });
|
||||
case 'token-bucket':
|
||||
default:
|
||||
return new TokenBucketStrategy({ rate, capacity });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
export function getPlayerRateLimitContext(player: Player): IRateLimitContext | null {
|
||||
const data = player as unknown as Record<symbol, unknown>;
|
||||
return (data[PLAYER_RATE_LIMIT_CONTEXT] as IRateLimitContext) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置玩家的速率限制上下文
|
||||
* @en Set rate limit context for player
|
||||
*/
|
||||
function setPlayerRateLimitContext(player: Player, context: IRateLimitContext): void {
|
||||
const data = player as unknown as Record<symbol, unknown>;
|
||||
data[PLAYER_RATE_LIMIT_CONTEXT] = context;
|
||||
|
||||
Object.defineProperty(player, 'rateLimit', {
|
||||
get: () => data[PLAYER_RATE_LIMIT_CONTEXT],
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装房间类添加速率限制功能
|
||||
* @en Wrap room class with rate limit functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为房间添加速率限制,在消息处理前验证速率限制
|
||||
* @en Uses mixin pattern to add rate limit to room, validates rate before processing messages
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import { withRateLimit } from '@esengine/server/ratelimit';
|
||||
*
|
||||
* class GameRoom extends withRateLimit(Room, {
|
||||
* messagesPerSecond: 10,
|
||||
* burstSize: 20,
|
||||
* onLimited: (player, type, result) => {
|
||||
* player.send('Error', {
|
||||
* code: 'RATE_LIMITED',
|
||||
* retryAfter: result.retryAfter
|
||||
* });
|
||||
* }
|
||||
* }) {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // Protected by rate limit
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* // Combine with auth
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(
|
||||
* withRoomAuth(Room, { requireAuth: true }),
|
||||
* { messagesPerSecond: 10 }
|
||||
* ) {
|
||||
* // Both auth and rate limit active
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function withRateLimit<TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||
Base: TBase,
|
||||
config: RateLimitConfig = {}
|
||||
): TBase & (new (...args: any[]) => IRateLimitRoom) {
|
||||
const {
|
||||
messagesPerSecond = 10,
|
||||
burstSize = 20,
|
||||
strategy = 'token-bucket',
|
||||
onLimited,
|
||||
disconnectOnLimit = false,
|
||||
maxConsecutiveLimits = 0,
|
||||
getKey = (player: Player) => player.id,
|
||||
cleanupInterval = 60000
|
||||
} = config;
|
||||
|
||||
abstract class RateLimitRoom extends (Base as new (...args: any[]) => Room) implements IRateLimitRoom {
|
||||
private _rateLimitStrategy: IRateLimitStrategy;
|
||||
private _playerContexts: WeakMap<Player, RateLimitContext> = new WeakMap();
|
||||
private _cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
this._rateLimitStrategy = createStrategy({
|
||||
messagesPerSecond,
|
||||
burstSize,
|
||||
strategy
|
||||
});
|
||||
|
||||
this._startCleanup();
|
||||
this._initMessageStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
get rateLimitStrategy(): IRateLimitStrategy {
|
||||
return this._rateLimitStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制钩子(可覆盖)
|
||||
* @en Rate limit hook (can be overridden)
|
||||
*/
|
||||
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
|
||||
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null {
|
||||
return this._playerContexts.get(player) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写消息处理以添加速率限制检查
|
||||
* @en Override message handling to add rate limit check
|
||||
*/
|
||||
_handleMessage(type: string, data: unknown, playerId: string): void {
|
||||
const player = this.getPlayer(playerId);
|
||||
if (!player) return;
|
||||
|
||||
let context = this._playerContexts.get(player);
|
||||
if (!context) {
|
||||
context = this._createPlayerContext(player);
|
||||
}
|
||||
|
||||
const metadata = this._getMessageMetadata(type);
|
||||
|
||||
if (metadata?.exempt) {
|
||||
super._handleMessage(type, data, playerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = metadata?.config?.cost ?? 1;
|
||||
let result: RateLimitResult;
|
||||
|
||||
if (metadata?.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
|
||||
if (!context.hasMessageStrategy(type)) {
|
||||
const msgStrategy = createStrategy({
|
||||
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
|
||||
burstSize: metadata.config.burstSize ?? burstSize,
|
||||
strategy
|
||||
});
|
||||
context.setMessageStrategy(type, msgStrategy);
|
||||
}
|
||||
result = context.consume(type, cost);
|
||||
} else {
|
||||
result = context.consume(undefined, cost);
|
||||
}
|
||||
|
||||
if (!result.allowed) {
|
||||
this._handleRateLimited(player, type, result, context);
|
||||
return;
|
||||
}
|
||||
|
||||
super._handleMessage(type, data, playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写 _addPlayer 以初始化速率限制上下文
|
||||
* @en Override _addPlayer to initialize rate limit context
|
||||
*/
|
||||
async _addPlayer(id: string, conn: any): Promise<Player | null> {
|
||||
const player = await super._addPlayer(id, conn);
|
||||
if (player) {
|
||||
this._createPlayerContext(player);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写 _removePlayer 以清理速率限制上下文
|
||||
* @en Override _removePlayer to cleanup rate limit context
|
||||
*/
|
||||
async _removePlayer(id: string, reason?: string): Promise<void> {
|
||||
const player = this.getPlayer(id);
|
||||
if (player) {
|
||||
const context = this._playerContexts.get(player);
|
||||
if (context) {
|
||||
context.reset();
|
||||
}
|
||||
this._playerContexts.delete(player);
|
||||
}
|
||||
await super._removePlayer(id, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重写 dispose 以清理定时器
|
||||
* @en Override dispose to cleanup timer
|
||||
*/
|
||||
dispose(): void {
|
||||
this._stopCleanup();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建玩家的速率限制上下文
|
||||
* @en Create rate limit context for player
|
||||
*/
|
||||
private _createPlayerContext(player: Player): RateLimitContext {
|
||||
const key = getKey(player);
|
||||
const context = new RateLimitContext(key, this._rateLimitStrategy);
|
||||
this._playerContexts.set(player, context);
|
||||
setPlayerRateLimitContext(player, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理被限流的情况
|
||||
* @en Handle rate limited situation
|
||||
*/
|
||||
private _handleRateLimited(
|
||||
player: Player,
|
||||
messageType: string,
|
||||
result: RateLimitResult,
|
||||
context: RateLimitContext
|
||||
): void {
|
||||
if (this.onRateLimited) {
|
||||
this.onRateLimited(player, messageType, result);
|
||||
}
|
||||
|
||||
onLimited?.(player, messageType, result);
|
||||
|
||||
if (disconnectOnLimit) {
|
||||
this.kick(player as any, 'rate_limited');
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxConsecutiveLimits > 0 && context.consecutiveLimitCount >= maxConsecutiveLimits) {
|
||||
this.kick(player as any, 'too_many_rate_limits');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取消息的元数据
|
||||
* @en Get message metadata
|
||||
*/
|
||||
private _getMessageMetadata(type: string): RateLimitMetadata | undefined {
|
||||
return getRateLimitMetadata(this.constructor.prototype, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 初始化消息策略(从装饰器元数据)
|
||||
* @en Initialize message strategies (from decorator metadata)
|
||||
*/
|
||||
private _initMessageStrategies(): void {
|
||||
const metadataMap = (this.constructor.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||
if (metadataMap instanceof Map) {
|
||||
for (const [msgType, metadata] of metadataMap) {
|
||||
if (metadata.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
|
||||
const msgStrategy = createStrategy({
|
||||
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
|
||||
burstSize: metadata.config.burstSize ?? burstSize,
|
||||
strategy
|
||||
});
|
||||
this._messageStrategies.set(msgType, msgStrategy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 开始清理定时器
|
||||
* @en Start cleanup timer
|
||||
*/
|
||||
private _startCleanup(): void {
|
||||
if (cleanupInterval > 0) {
|
||||
this._cleanupTimer = setInterval(() => {
|
||||
this._rateLimitStrategy.cleanup();
|
||||
for (const strategy of this._messageStrategies.values()) {
|
||||
strategy.cleanup();
|
||||
}
|
||||
}, cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 停止清理定时器
|
||||
* @en Stop cleanup timer
|
||||
*/
|
||||
private _stopCleanup(): void {
|
||||
if (this._cleanupTimer) {
|
||||
clearInterval(this._cleanupTimer);
|
||||
this._cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RateLimitRoom as unknown as TBase & (new (...args: any[]) => IRateLimitRoom);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @zh 固定窗口速率限制策略
|
||||
* @en Fixed window rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 固定窗口状态
|
||||
* @en Fixed window state
|
||||
*/
|
||||
interface WindowState {
|
||||
/**
|
||||
* @zh 当前窗口计数
|
||||
* @en Current window count
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* @zh 窗口开始时间
|
||||
* @en Window start time
|
||||
*/
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 固定窗口速率限制策略
|
||||
* @en Fixed window rate limit strategy
|
||||
*
|
||||
* @zh 固定窗口算法将时间划分为固定长度的窗口,在每个窗口内计数请求。
|
||||
* 实现简单,内存开销小,但在窗口边界可能有两倍突发的问题。
|
||||
* @en Fixed window algorithm divides time into fixed-length windows and counts requests in each window.
|
||||
* Simple to implement with low memory overhead, but may have 2x burst issue at window boundaries.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new FixedWindowStrategy({
|
||||
* rate: 10, // 10 requests per second
|
||||
* capacity: 10 // same as rate for 1-second window
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FixedWindowStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'fixed-window';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _windowMs: number;
|
||||
private _windows: Map<string, WindowState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建固定窗口策略
|
||||
* @en Create fixed window strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
|
||||
* @param config.capacity - @zh 窗口容量 @en Window capacity
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
this._windowMs = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._getOrCreateWindow(key, now);
|
||||
|
||||
this._maybeResetWindow(window, now);
|
||||
|
||||
if (window.count + cost <= this._capacity) {
|
||||
window.count += cost;
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity - window.count,
|
||||
resetAt: window.windowStart + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
const retryAfter = window.windowStart + this._windowMs - now;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: window.windowStart + this._windowMs,
|
||||
retryAfter: Math.max(0, retryAfter)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: this._getWindowStart(now) + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
this._maybeResetWindow(window, now);
|
||||
|
||||
const remaining = Math.max(0, this._capacity - window.count);
|
||||
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
resetAt: window.windowStart + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期记录
|
||||
* @en Clean up all expired records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const currentWindowStart = this._getWindowStart(now);
|
||||
|
||||
for (const [key, window] of this._windows) {
|
||||
if (window.windowStart < currentWindowStart - this._windowMs) {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建窗口
|
||||
* @en Get or create window
|
||||
*/
|
||||
private _getOrCreateWindow(key: string, now: number): WindowState {
|
||||
let window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
window = {
|
||||
count: 0,
|
||||
windowStart: this._getWindowStart(now)
|
||||
};
|
||||
this._windows.set(key, window);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 如果需要则重置窗口
|
||||
* @en Reset window if needed
|
||||
*/
|
||||
private _maybeResetWindow(window: WindowState, now: number): void {
|
||||
const currentWindowStart = this._getWindowStart(now);
|
||||
|
||||
if (window.windowStart < currentWindowStart) {
|
||||
window.count = 0;
|
||||
window.windowStart = currentWindowStart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取窗口开始时间
|
||||
* @en Get window start time
|
||||
*/
|
||||
private _getWindowStart(now: number): number {
|
||||
return Math.floor(now / this._windowMs) * this._windowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建固定窗口策略
|
||||
* @en Create fixed window strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createFixedWindowStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 10
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createFixedWindowStrategy(config: StrategyConfig): FixedWindowStrategy {
|
||||
return new FixedWindowStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @zh 滑动窗口速率限制策略
|
||||
* @en Sliding window rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 滑动窗口状态
|
||||
* @en Sliding window state
|
||||
*/
|
||||
interface WindowState {
|
||||
/**
|
||||
* @zh 请求时间戳列表
|
||||
* @en Request timestamp list
|
||||
*/
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 滑动窗口速率限制策略
|
||||
* @en Sliding window rate limit strategy
|
||||
*
|
||||
* @zh 滑动窗口算法精确跟踪时间窗口内的请求数。
|
||||
* 比固定窗口更精确,但内存开销稍大。
|
||||
* @en Sliding window algorithm precisely tracks requests within a time window.
|
||||
* More accurate than fixed window, but with slightly higher memory overhead.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new SlidingWindowStrategy({
|
||||
* rate: 10, // 10 requests per second
|
||||
* capacity: 10 // window size (same as rate for 1-second window)
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SlidingWindowStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'sliding-window';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _windowMs: number;
|
||||
private _windows: Map<string, WindowState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建滑动窗口策略
|
||||
* @en Create sliding window strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
|
||||
* @param config.capacity - @zh 窗口容量 @en Window capacity
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
this._windowMs = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._getOrCreateWindow(key);
|
||||
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
const currentCount = window.timestamps.length;
|
||||
|
||||
if (currentCount + cost <= this._capacity) {
|
||||
for (let i = 0; i < cost; i++) {
|
||||
window.timestamps.push(now);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity - window.timestamps.length,
|
||||
resetAt: this._getResetAt(window, now)
|
||||
};
|
||||
}
|
||||
|
||||
const oldestTimestamp = window.timestamps[0] || now;
|
||||
const retryAfter = Math.max(0, oldestTimestamp + this._windowMs - now);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: oldestTimestamp + this._windowMs,
|
||||
retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: now + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
const remaining = Math.max(0, this._capacity - window.timestamps.length);
|
||||
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
resetAt: this._getResetAt(window, now)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期记录
|
||||
* @en Clean up all expired records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, window] of this._windows) {
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
if (window.timestamps.length === 0) {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建窗口
|
||||
* @en Get or create window
|
||||
*/
|
||||
private _getOrCreateWindow(key: string): WindowState {
|
||||
let window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
window = { timestamps: [] };
|
||||
this._windows.set(key, window);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理过期的时间戳
|
||||
* @en Clean expired timestamps
|
||||
*/
|
||||
private _cleanExpiredTimestamps(window: WindowState, now: number): void {
|
||||
const cutoff = now - this._windowMs;
|
||||
window.timestamps = window.timestamps.filter(ts => ts > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取重置时间
|
||||
* @en Get reset time
|
||||
*/
|
||||
private _getResetAt(window: WindowState, now: number): number {
|
||||
if (window.timestamps.length === 0) {
|
||||
return now + this._windowMs;
|
||||
}
|
||||
|
||||
return window.timestamps[0] + this._windowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建滑动窗口策略
|
||||
* @en Create sliding window strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createSlidingWindowStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 10
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createSlidingWindowStrategy(config: StrategyConfig): SlidingWindowStrategy {
|
||||
return new SlidingWindowStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @zh 令牌桶速率限制策略
|
||||
* @en Token bucket rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 令牌桶状态
|
||||
* @en Token bucket state
|
||||
*/
|
||||
interface BucketState {
|
||||
/**
|
||||
* @zh 当前令牌数
|
||||
* @en Current token count
|
||||
*/
|
||||
tokens: number;
|
||||
|
||||
/**
|
||||
* @zh 上次更新时间
|
||||
* @en Last update time
|
||||
*/
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 令牌桶速率限制策略
|
||||
* @en Token bucket rate limit strategy
|
||||
*
|
||||
* @zh 令牌桶算法允许突发流量,同时保持长期速率限制。
|
||||
* 令牌以固定速率添加到桶中,每个请求消耗一个或多个令牌。
|
||||
* @en Token bucket algorithm allows burst traffic while maintaining long-term rate limit.
|
||||
* Tokens are added to the bucket at a fixed rate, each request consumes one or more tokens.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new TokenBucketStrategy({
|
||||
* rate: 10, // 10 tokens per second
|
||||
* capacity: 20 // bucket can hold 20 tokens max
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class TokenBucketStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'token-bucket';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _buckets: Map<string, BucketState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建令牌桶策略
|
||||
* @en Create token bucket strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒添加的令牌数 @en Tokens added per second
|
||||
* @param config.capacity - @zh 桶容量(最大令牌数)@en Bucket capacity (max tokens)
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费令牌
|
||||
* @en Try to consume tokens
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const bucket = this._getOrCreateBucket(key, now);
|
||||
|
||||
this._refillBucket(bucket, now);
|
||||
|
||||
if (bucket.tokens >= cost) {
|
||||
bucket.tokens -= cost;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.floor(bucket.tokens),
|
||||
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
const tokensNeeded = cost - bucket.tokens;
|
||||
const retryAfter = Math.ceil(tokensNeeded / this._rate * 1000);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: now + retryAfter,
|
||||
retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const bucket = this._buckets.get(key);
|
||||
|
||||
if (!bucket) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: now
|
||||
};
|
||||
}
|
||||
|
||||
this._refillBucket(bucket, now);
|
||||
|
||||
return {
|
||||
allowed: bucket.tokens >= 1,
|
||||
remaining: Math.floor(bucket.tokens),
|
||||
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._buckets.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有记录
|
||||
* @en Clean up all records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const expireThreshold = 60000;
|
||||
|
||||
for (const [key, bucket] of this._buckets) {
|
||||
if (now - bucket.lastUpdate > expireThreshold && bucket.tokens >= this._capacity) {
|
||||
this._buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建桶
|
||||
* @en Get or create bucket
|
||||
*/
|
||||
private _getOrCreateBucket(key: string, now: number): BucketState {
|
||||
let bucket = this._buckets.get(key);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
tokens: this._capacity,
|
||||
lastUpdate: now
|
||||
};
|
||||
this._buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 补充令牌
|
||||
* @en Refill tokens
|
||||
*/
|
||||
private _refillBucket(bucket: BucketState, now: number): void {
|
||||
const elapsed = now - bucket.lastUpdate;
|
||||
const tokensToAdd = (elapsed / 1000) * this._rate;
|
||||
|
||||
bucket.tokens = Math.min(this._capacity, bucket.tokens + tokensToAdd);
|
||||
bucket.lastUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建令牌桶策略
|
||||
* @en Create token bucket strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createTokenBucketStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 20
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createTokenBucketStrategy(config: StrategyConfig): TokenBucketStrategy {
|
||||
return new TokenBucketStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @zh 速率限制策略
|
||||
* @en Rate limit strategies
|
||||
*/
|
||||
|
||||
export { TokenBucketStrategy, createTokenBucketStrategy } from './TokenBucket.js';
|
||||
export { SlidingWindowStrategy, createSlidingWindowStrategy } from './SlidingWindow.js';
|
||||
export { FixedWindowStrategy, createFixedWindowStrategy } from './FixedWindow.js';
|
||||
267
packages/framework/server/src/ratelimit/types.ts
Normal file
267
packages/framework/server/src/ratelimit/types.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @zh 速率限制类型定义
|
||||
* @en Rate limit type definitions
|
||||
*/
|
||||
|
||||
import type { Player } from '../room/Player.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制结果
|
||||
* @en Rate limit result
|
||||
*/
|
||||
export interface RateLimitResult {
|
||||
/**
|
||||
* @zh 是否允许
|
||||
* @en Whether allowed
|
||||
*/
|
||||
allowed: boolean;
|
||||
|
||||
/**
|
||||
* @zh 剩余配额
|
||||
* @en Remaining quota
|
||||
*/
|
||||
remaining: number;
|
||||
|
||||
/**
|
||||
* @zh 配额重置时间(毫秒时间戳)
|
||||
* @en Quota reset time (milliseconds timestamp)
|
||||
*/
|
||||
resetAt: number;
|
||||
|
||||
/**
|
||||
* @zh 重试等待时间(毫秒),仅在被限流时返回
|
||||
* @en Retry after time (milliseconds), only returned when rate limited
|
||||
*/
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略接口
|
||||
* @en Rate limit strategy interface
|
||||
*/
|
||||
export interface IRateLimitStrategy {
|
||||
/**
|
||||
* @zh 策略名称
|
||||
* @en Strategy name
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*
|
||||
* @param key - @zh 限流键(通常是玩家ID或连接ID)@en Rate limit key (usually player ID or connection ID)
|
||||
* @param cost - @zh 消费数量(默认1)@en Consumption amount (default 1)
|
||||
* @returns @zh 限流结果 @en Rate limit result
|
||||
*/
|
||||
consume(key: string, cost?: number): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态(不消费)
|
||||
* @en Get current status (without consuming)
|
||||
*
|
||||
* @param key - @zh 限流键 @en Rate limit key
|
||||
* @returns @zh 限流结果 @en Rate limit result
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 重置指定键的限流状态
|
||||
* @en Reset rate limit status for specified key
|
||||
*
|
||||
* @param key - @zh 限流键 @en Rate limit key
|
||||
*/
|
||||
reset(key: string): void;
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期的限流记录
|
||||
* @en Clean up all expired rate limit records
|
||||
*/
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略类型
|
||||
* @en Rate limit strategy type
|
||||
*/
|
||||
export type RateLimitStrategyType = 'token-bucket' | 'sliding-window' | 'fixed-window';
|
||||
|
||||
/**
|
||||
* @zh 速率限制配置
|
||||
* @en Rate limit configuration
|
||||
*/
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* @zh 每秒允许的消息数
|
||||
* @en Messages allowed per second
|
||||
* @defaultValue 10
|
||||
*/
|
||||
messagesPerSecond?: number;
|
||||
|
||||
/**
|
||||
* @zh 突发容量(令牌桶大小)
|
||||
* @en Burst capacity (token bucket size)
|
||||
* @defaultValue 20
|
||||
*/
|
||||
burstSize?: number;
|
||||
|
||||
/**
|
||||
* @zh 限流策略
|
||||
* @en Rate limit strategy
|
||||
* @defaultValue 'token-bucket'
|
||||
*/
|
||||
strategy?: RateLimitStrategyType;
|
||||
|
||||
/**
|
||||
* @zh 被限流时的回调
|
||||
* @en Callback when rate limited
|
||||
*/
|
||||
onLimited?: (player: Player, messageType: string, result: RateLimitResult) => void;
|
||||
|
||||
/**
|
||||
* @zh 是否在限流时断开连接
|
||||
* @en Whether to disconnect when rate limited
|
||||
* @defaultValue false
|
||||
*/
|
||||
disconnectOnLimit?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 连续被限流多少次后断开连接(0 表示不断开)
|
||||
* @en Disconnect after how many consecutive rate limits (0 means never)
|
||||
* @defaultValue 0
|
||||
*/
|
||||
maxConsecutiveLimits?: number;
|
||||
|
||||
/**
|
||||
* @zh 获取限流键的函数(默认使用玩家ID)
|
||||
* @en Function to get rate limit key (default uses player ID)
|
||||
*/
|
||||
getKey?: (player: Player) => string;
|
||||
|
||||
/**
|
||||
* @zh 清理间隔(毫秒)
|
||||
* @en Cleanup interval (milliseconds)
|
||||
* @defaultValue 60000
|
||||
*/
|
||||
cleanupInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 单个消息的速率限制配置
|
||||
* @en Rate limit configuration for individual message
|
||||
*/
|
||||
export interface MessageRateLimitConfig {
|
||||
/**
|
||||
* @zh 每秒允许的消息数
|
||||
* @en Messages allowed per second
|
||||
*/
|
||||
messagesPerSecond?: number;
|
||||
|
||||
/**
|
||||
* @zh 突发容量
|
||||
* @en Burst capacity
|
||||
*/
|
||||
burstSize?: number;
|
||||
|
||||
/**
|
||||
* @zh 消费的令牌数(默认1)
|
||||
* @en Tokens to consume (default 1)
|
||||
*/
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制元数据
|
||||
* @en Rate limit metadata
|
||||
*/
|
||||
export interface RateLimitMetadata {
|
||||
/**
|
||||
* @zh 是否启用速率限制
|
||||
* @en Whether rate limit is enabled
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* @zh 是否豁免速率限制
|
||||
* @en Whether exempt from rate limit
|
||||
*/
|
||||
exempt?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 自定义配置
|
||||
* @en Custom configuration
|
||||
*/
|
||||
config?: MessageRateLimitConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制上下文接口
|
||||
* @en Rate limit context interface
|
||||
*/
|
||||
export interface IRateLimitContext {
|
||||
/**
|
||||
* @zh 检查是否允许(不消费)
|
||||
* @en Check if allowed (without consuming)
|
||||
*/
|
||||
check(messageType?: string): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 消费配额
|
||||
* @en Consume quota
|
||||
*/
|
||||
consume(messageType?: string, cost?: number): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 重置限流状态
|
||||
* @en Reset rate limit status
|
||||
*/
|
||||
reset(messageType?: string): void;
|
||||
|
||||
/**
|
||||
* @zh 获取连续被限流次数
|
||||
* @en Get consecutive limit count
|
||||
*/
|
||||
readonly consecutiveLimitCount: number;
|
||||
|
||||
/**
|
||||
* @zh 重置连续限流计数
|
||||
* @en Reset consecutive limit count
|
||||
*/
|
||||
resetConsecutiveCount(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的 Room 接口
|
||||
* @en Room interface with rate limit
|
||||
*/
|
||||
export interface RateLimitedRoom {
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null;
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
readonly rateLimitStrategy: IRateLimitStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略配置
|
||||
* @en Rate limit strategy configuration
|
||||
*/
|
||||
export interface StrategyConfig {
|
||||
/**
|
||||
* @zh 每秒允许的请求数
|
||||
* @en Requests allowed per second
|
||||
*/
|
||||
rate: number;
|
||||
|
||||
/**
|
||||
* @zh 容量/窗口大小
|
||||
* @en Capacity/window size
|
||||
*/
|
||||
capacity: number;
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/testing/index.ts'],
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/auth/index.ts',
|
||||
'src/auth/testing/index.ts',
|
||||
'src/ratelimit/index.ts',
|
||||
'src/testing/index.ts',
|
||||
'src/ecs/index.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['ws', '@esengine/rpc', '@esengine/rpc/codec'],
|
||||
external: ['ws', 'jsonwebtoken', '@esengine/rpc', '@esengine/rpc/codec', '@esengine/ecs-framework'],
|
||||
treeshake: true,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/spatial
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/spatial",
|
||||
"version": "1.0.4",
|
||||
"version": "2.0.1",
|
||||
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/timer
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a08a84b`](https://github.com/esengine/esengine/commit/a08a84b7db28e1140cbc637d442552747ad81c76)]:
|
||||
- @esengine/ecs-framework@2.5.1
|
||||
- @esengine/blueprint@2.0.1
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/ecs-framework@2.5.0
|
||||
- @esengine/blueprint@2.0.0
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/timer",
|
||||
"version": "1.0.3",
|
||||
"version": "2.0.1",
|
||||
"description": "Timer and cooldown system for ECS Framework / ECS 框架的定时器和冷却系统",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @esengine/transaction
|
||||
|
||||
## 2.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f297ac`](https://github.com/esengine/esengine/commit/1f297ac769e37700f72fb4425639af7090898256)]:
|
||||
- @esengine/server@2.0.0
|
||||
|
||||
## 2.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70)]:
|
||||
- @esengine/server@1.3.0
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e)]:
|
||||
- @esengine/server@1.2.0
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/transaction",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @esengine/demos
|
||||
|
||||
## 1.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/fsm@2.0.1
|
||||
- @esengine/pathfinding@2.0.1
|
||||
- @esengine/procgen@2.0.1
|
||||
- @esengine/spatial@2.0.1
|
||||
- @esengine/timer@2.0.1
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/fsm@2.0.0
|
||||
- @esengine/pathfinding@2.0.0
|
||||
- @esengine/procgen@2.0.0
|
||||
- @esengine/spatial@2.0.0
|
||||
- @esengine/timer@2.0.0
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/demos",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"description": "Demo tests for ESEngine modules documentation",
|
||||
"type": "module",
|
||||
|
||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@@ -1688,12 +1688,21 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../rpc
|
||||
devDependencies:
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../core/dist
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.27
|
||||
'@types/ws':
|
||||
specifier: ^8.5.13
|
||||
version: 8.18.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.3
|
||||
rimraf:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
@@ -5183,6 +5192,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -5911,6 +5923,9 @@ packages:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -6604,6 +6619,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
editorconfig@0.15.3:
|
||||
resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==}
|
||||
hasBin: true
|
||||
@@ -7968,6 +7986,10 @@ packages:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
@@ -7977,6 +7999,12 @@ packages:
|
||||
just-diff@6.0.2:
|
||||
resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
k8w-crypto@0.2.0:
|
||||
resolution: {integrity: sha512-M6u4eQ6CQaU5xO3s4zaUUp9G79xNDhXtTU0X7N80tDcBhQC5ggowlyOzj95v7WiCuk7xkV0aFsTmCpuf0m0djw==}
|
||||
|
||||
@@ -8176,9 +8204,15 @@ packages:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
@@ -8186,9 +8220,15 @@ packages:
|
||||
lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.ismatch@4.4.0:
|
||||
resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
@@ -8207,6 +8247,9 @@ packages:
|
||||
lodash.mergewith@4.6.2:
|
||||
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
@@ -14889,6 +14932,11 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 20.19.27
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
@@ -15159,6 +15207,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 1.2.0
|
||||
@@ -15871,6 +15927,8 @@ snapshots:
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -16506,6 +16564,10 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
editorconfig@0.15.3:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
@@ -18369,6 +18431,19 @@ snapshots:
|
||||
|
||||
jsonparse@1.3.1: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
@@ -18380,6 +18455,17 @@ snapshots:
|
||||
|
||||
just-diff@6.0.2: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
k8w-crypto@0.2.0: {}
|
||||
|
||||
k8w-extend-native@1.4.6:
|
||||
@@ -18645,14 +18731,22 @@ snapshots:
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.ismatch@4.4.0: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
@@ -18665,6 +18759,8 @@ snapshots:
|
||||
|
||||
lodash.mergewith@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash.startcase@4.4.0: {}
|
||||
@@ -22075,7 +22171,7 @@ snapshots:
|
||||
vitest@2.1.9(@types/node@22.19.3)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1))
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
|
||||
Reference in New Issue
Block a user