Compare commits

...

15 Commits

Author SHA1 Message Date
YHH
bdbbf8a80a feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁 (#395)
* docs: add editor-app README with setup instructions

* docs: add separate EN/CN editor setup guides

* feat(ecs): add @NetworkEntity decorator for auto spawn/despawn broadcasting

- Add @NetworkEntity decorator to mark components for automatic network broadcasting
- ECSRoom now auto-broadcasts spawn on component:added event
- ECSRoom now auto-broadcasts despawn on entity:destroyed event
- Entity.destroy() emits entity:destroyed event via ECSEventType
- Entity active state changes emit ENTITY_ENABLED/ENTITY_DISABLED events
- Add enableAutoNetworkEntity config option to ECSRoom (default true)
- Update documentation for both Chinese and English
2025-12-30 16:19:01 +08:00
YHH
b28169b186 fix(editor): fix build errors and refactor behavior-tree architecture (#394)
* docs: add editor-app README with setup instructions

* docs: add separate EN/CN editor setup guides

* fix(editor): fix build errors and refactor behavior-tree architecture

- Fix fairygui-editor tsconfig extends path and add missing tsconfig.build.json
- Refactor behavior-tree-editor to not depend on asset-system in runtime
  - Create local BehaviorTreeRuntimeModule for pure runtime logic
  - Move asset loader registration to editor module install()
  - Add BehaviorTreeLoader for asset system integration
- Fix rapier2d WASM loader to not pass arguments to init()
- Add WASM base64 loader config to rapier2d tsup.config
- Update README documentation and simplify setup steps
2025-12-30 11:13:26 +08:00
github-actions[bot]
d21caa974e chore: release packages (#393)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-30 09:41:17 +08:00
YHH
a08a84b7db fix(sync): use GlobalComponentRegistry for network sync decoding (#392)
- Decoder.ts now uses GlobalComponentRegistry.getComponentType() instead of local registry
- @sync decorator uses getComponentTypeName() to get @ECSComponent decorator name
- @ECSComponent decorator updates SYNC_METADATA.typeId when defined
- Removed deprecated registerSyncComponent/autoRegisterSyncComponent functions
- Updated ComponentSync.ts in network package to use GlobalComponentRegistry
- Updated tests to use correct @ECSComponent type names

This ensures that components decorated with @ECSComponent are automatically
available for network sync decoding without any manual registration.
2025-12-30 09:39:17 +08:00
github-actions[bot]
449bd420a6 chore: release packages (#391)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 21:10:36 +08:00
YHH
1f297ac769 feat(ecs): ECS 网络状态同步系统 | add ECS network state synchronization (#390)
## @esengine/ecs-framework

新增 @sync 装饰器和二进制编解码器,支持基于 Component 的网络状态同步:

- `sync` 装饰器标记需要同步的字段
- `ChangeTracker` 组件变更追踪
- 二进制编解码器 (BinaryWriter/BinaryReader)
- `encodeSnapshot`/`decodeSnapshot` 批量编解码
- `encodeSpawn`/`decodeSpawn` 实体生成编解码
- `encodeDespawn`/`processDespawn` 实体销毁编解码

将以下方法标记为 @internal,用户应通过 Core.update() 驱动更新:
- Scene.update()
- SceneManager.update()
- WorldManager.updateAll()

## @esengine/network

- 新增 ComponentSyncSystem 基于 @sync 自动同步组件状态
- 将 ecs-framework 从 devDependencies 移到 peerDependencies

## @esengine/server

新增 ECSRoom,带有 ECS World 支持的房间基类:

- 每个 ECSRoom 在 Core.worldManager 中创建独立的 World
- Core.update() 统一更新 Time 和所有 World
- onTick() 只处理状态同步逻辑
- 自动创建/销毁玩家实体
- 增量状态广播
2025-12-29 21:08:34 +08:00
github-actions[bot]
4cf868a769 chore: release packages (#389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 17:14:53 +08:00
YHH
afdeb00b4d feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system (#388)
* feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system

- 新增令牌桶策略 (TokenBucketStrategy) - 推荐用于一般场景
- 新增滑动窗口策略 (SlidingWindowStrategy) - 精确跟踪
- 新增固定窗口策略 (FixedWindowStrategy) - 简单高效
- 新增房间速率限制 mixin (withRateLimit)
- 新增速率限制装饰器 (@rateLimit, @noRateLimit)
- 新增按消息类型限流装饰器 (@rateLimitMessage, @noRateLimitMessage)
- 支持与认证系统组合使用
- 添加中英文文档
- 导出路径: @esengine/server/ratelimit

* docs: 更新 README 添加新模块 | update README with new modules

- 添加程序化生成 (procgen) 模块
- 添加 RPC 框架模块
- 添加游戏服务器 (server) 模块
- 添加事务系统 (transaction) 模块
- 添加世界流送 (world-streaming) 模块
- 更新网络模块描述
- 更新项目结构目录
2025-12-29 17:12:54 +08:00
github-actions[bot]
764ce67742 chore: release packages (#387)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 16:12:03 +08:00
YHH
61a13baca2 feat(server): 添加可插拔认证系统 | add pluggable authentication system (#386)
* feat(server): 添加可插拔认证系统 | add pluggable authentication system

- 新增 JWT 认证提供者 (createJwtAuthProvider)
- 新增 Session 认证提供者 (createSessionAuthProvider)
- 新增服务器认证 mixin (withAuth)
- 新增房间认证 mixin (withRoomAuth)
- 新增认证装饰器 (@requireAuth, @requireRole)
- 新增测试工具 (MockAuthProvider)
- 新增中英文文档
- 导出路径: @esengine/server/auth, @esengine/server/auth/testing

* fix(server): 使用加密安全的随机数生成 session ID | use crypto-secure random for session ID
2025-12-29 16:10:09 +08:00
github-actions[bot]
1cfa64aa0f chore: release packages (#385)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 15:04:07 +08:00
YHH
3b978384c7 feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
2025-12-29 15:02:13 +08:00
YHH
10c3891abd docs: 添加缺失的侧边栏导航项 | add missing sidebar items (#383)
- RPC: 添加 server, client, codec 页面
- Network: 添加 prediction, aoi, delta 页面
- Transaction: 添加完整模块导航
- Changelog: 添加 transaction, rpc 链接
2025-12-29 11:29:42 +08:00
github-actions[bot]
18af48a0fc chore: release packages (#382)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 11:08:46 +08:00
YHH
d4cef828e1 feat(transaction): 添加游戏事务系统 | add game transaction system (#381)
- TransactionManager/TransactionContext 事务管理
- MemoryStorage/RedisStorage/MongoStorage 存储实现
- CurrencyOperation/InventoryOperation/TradeOperation 内置操作
- SagaOrchestrator 分布式 Saga 编排
- withTransactions() Room 集成
- 完整中英文文档
2025-12-29 10:54:00 +08:00
177 changed files with 27951 additions and 712 deletions

View File

@@ -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)
@@ -218,6 +228,7 @@ If you want a complete engine solution with rendering:
A visual editor built with Tauri for scene management:
- Download from [Releases](https://github.com/esengine/esengine/releases)
- [Build from source](./packages/editor/editor-app/README.md)
- Supports behavior tree editing, tilemap painting, visual scripting
## Project Structure
@@ -235,7 +246,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
@@ -267,6 +282,7 @@ pnpm test
- [ECS Framework Guide](./packages/framework/core/README.md)
- [Behavior Tree Guide](./packages/framework/behavior-tree/README.md)
- [Editor Setup Guide](./packages/editor/editor-app/README.md) ([中文](./packages/editor/editor-app/README_CN.md))
- [API Reference](https://esengine.cn/api/README)
## Community

View File

@@ -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 运行时(可选)
@@ -218,6 +228,7 @@ npm install @esengine/network # 网络
基于 Tauri 构建的可视化编辑器:
- 从 [Releases](https://github.com/esengine/esengine/releases) 下载
- [从源码构建](./packages/editor/editor-app/README.md)
- 支持行为树编辑、Tilemap 绘制、可视化脚本
## 项目结构
@@ -235,7 +246,11 @@ esengine/
│ │ ├── spatial/ # 空间查询
│ │ ├── pathfinding/ # 寻路
│ │ ├── procgen/ # 程序化生成
│ │ ── network/ # 网络
│ │ ── rpc/ # RPC 框架
│ │ ├── server/ # 游戏服务器
│ │ ├── network/ # 客户端网络
│ │ ├── transaction/ # 事务系统
│ │ └── world-streaming/ # 世界流送
│ │
│ ├── engine/ # ESEngine 运行时
│ ├── rendering/ # 渲染模块
@@ -267,6 +282,7 @@ pnpm test
- [ECS 框架指南](./packages/framework/core/README.md)
- [行为树指南](./packages/framework/behavior-tree/README.md)
- [编辑器启动指南](./packages/editor/editor-app/README_CN.md) ([English](./packages/editor/editor-app/README.md))
- [API 参考](https://esengine.cn/api/README)
## 社区

View File

@@ -255,6 +255,9 @@ export default defineConfig({
translations: { en: 'RPC' },
items: [
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
{ label: '服务端', slug: 'modules/rpc/server', translations: { en: 'Server' } },
{ label: '客户端', slug: 'modules/rpc/client', translations: { en: 'Client' } },
{ label: '编解码', slug: 'modules/rpc/codec', translations: { en: 'Codec' } },
],
},
{
@@ -264,10 +267,26 @@ 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' } },
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
],
},
{
label: '事务系统',
translations: { en: 'Transaction' },
items: [
{ label: '概述', slug: 'modules/transaction', translations: { en: 'Overview' } },
{ label: '核心概念', slug: 'modules/transaction/core', translations: { en: 'Core Concepts' } },
{ label: '存储层', slug: 'modules/transaction/storage', translations: { en: 'Storage Layer' } },
{ label: '操作', slug: 'modules/transaction/operations', translations: { en: 'Operations' } },
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
],
},
{
label: '世界流式加载',
translations: { en: 'World Streaming' },
@@ -303,6 +322,8 @@ export default defineConfig({
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
{ label: '@esengine/transaction', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/transaction/CHANGELOG.md', attrs: { target: '_blank' } },
{ label: '@esengine/rpc', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/rpc/CHANGELOG.md', attrs: { target: '_blank' } },
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
],
},

View File

@@ -34,6 +34,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
| Module | Package | Description |
|--------|---------|-------------|
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support |
## Installation

View 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.

View 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}`)
}
}
```

View File

@@ -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**

View File

@@ -1,8 +1,176 @@
---
title: "State Sync"
description: "Interpolation, prediction and snapshot buffers"
description: "Component sync, interpolation, prediction and snapshot buffers"
---
## @NetworkEntity Decorator
The `@NetworkEntity` decorator marks components for automatic spawn/despawn broadcasting. When an entity containing this component is created or destroyed, ECSRoom automatically broadcasts the corresponding message to all clients.
### Basic Usage
```typescript
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
@ECSComponent('Enemy')
@NetworkEntity('Enemy')
class EnemyComponent extends Component {
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
@sync('uint16') health: number = 100;
}
```
When adding this component to an entity, ECSRoom automatically broadcasts the spawn message:
```typescript
// Server-side
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // Auto-broadcasts spawn
// Destroying auto-broadcasts despawn
entity.destroy(); // Auto-broadcasts despawn
```
### Configuration Options
```typescript
@NetworkEntity('Bullet', {
autoSpawn: true, // Auto-broadcast spawn (default true)
autoDespawn: false // Disable auto-broadcast despawn
})
class BulletComponent extends Component { }
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `autoSpawn` | `boolean` | `true` | Auto-broadcast spawn when component is added |
| `autoDespawn` | `boolean` | `true` | Auto-broadcast despawn when entity is destroyed |
### Initialization Order
When using `@NetworkEntity`, initialize data **before** adding the component:
```typescript
// ✅ Correct: Initialize first, then add
const comp = new PlayerComponent();
comp.playerId = player.id;
comp.x = 100;
comp.y = 200;
entity.addComponent(comp); // Data is correct at spawn
// ❌ Wrong: Add first, then initialize
const comp = entity.addComponent(new PlayerComponent());
comp.playerId = player.id; // Data has default values at spawn
```
### Simplified GameRoom
With `@NetworkEntity`, GameRoom becomes much cleaner:
```typescript
// No manual callbacks needed
class GameRoom extends ECSRoom {
private setupSystems(): void {
// Enemy spawn system (auto-broadcasts spawn)
this.addSystem(new EnemySpawnSystem());
// Enemy AI system
const enemyAI = new EnemyAISystem();
enemyAI.onDeath((enemy) => {
enemy.destroy(); // Auto-broadcasts despawn
});
this.addSystem(enemyAI);
}
}
```
### ECSRoom Configuration
You can disable the auto network entity feature in ECSRoom:
```typescript
class GameRoom extends ECSRoom {
constructor() {
super({
enableAutoNetworkEntity: false // Disable auto-broadcasting
});
}
}
```
## 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:

View File

@@ -0,0 +1,261 @@
---
title: "Core Concepts"
description: "Transaction system core concepts: context, manager, and Saga pattern"
---
## Transaction State
A transaction can be in the following states:
```typescript
type TransactionState =
| 'pending' // Waiting to execute
| 'executing' // Executing
| 'committed' // Committed
| 'rolledback' // Rolled back
| 'failed' // Failed
```
## TransactionContext
The transaction context encapsulates transaction state, operations, and execution logic.
### Creating Transactions
```typescript
import { TransactionManager } from '@esengine/transaction';
const manager = new TransactionManager();
// Method 1: Manual management with begin()
const tx = manager.begin({ timeout: 5000 });
tx.addOperation(op1);
tx.addOperation(op2);
const result = await tx.execute();
// Method 2: Automatic management with run()
const result = await manager.run((tx) => {
tx.addOperation(op1);
tx.addOperation(op2);
});
```
### Chaining Operations
```typescript
const result = await manager.run((tx) => {
tx.addOperation(new CurrencyOperation({ ... }))
.addOperation(new InventoryOperation({ ... }))
.addOperation(new InventoryOperation({ ... }));
});
```
### Context Data
Operations can share data through the context:
```typescript
class CustomOperation extends BaseOperation<MyData, MyResult> {
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// Read data set by previous operations
const previousResult = ctx.get<number>('previousValue');
// Set data for subsequent operations
ctx.set('myResult', { value: 123 });
return this.success({ ... });
}
}
```
## TransactionManager
The transaction manager is responsible for creating, executing, and recovering transactions.
### Configuration Options
```typescript
interface TransactionManagerConfig {
storage?: ITransactionStorage; // Storage instance
defaultTimeout?: number; // Default timeout (ms)
serverId?: string; // Server ID (for distributed)
autoRecover?: boolean; // Auto-recover pending transactions
}
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
defaultTimeout: 10000,
serverId: 'server-1',
autoRecover: true,
});
```
### Distributed Locking
```typescript
// Acquire lock
const token = await manager.acquireLock('player:123:inventory', 10000);
if (token) {
try {
// Perform operations
await doSomething();
} finally {
// Release lock
await manager.releaseLock('player:123:inventory', token);
}
}
// Or use withLock for convenience
await manager.withLock('player:123:inventory', async () => {
await doSomething();
}, 10000);
```
### Transaction Recovery
Recover pending transactions after server restart:
```typescript
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
serverId: 'server-1',
});
// Recover pending transactions
const recoveredCount = await manager.recover();
console.log(`Recovered ${recoveredCount} transactions`);
```
## Saga Pattern
The transaction system uses the Saga pattern. Each operation must implement `execute` and `compensate` methods:
```typescript
interface ITransactionOperation<TData, TResult> {
readonly name: string;
readonly data: TData;
// Validate preconditions
validate(ctx: ITransactionContext): Promise<boolean>;
// Forward execution
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
// Compensate (rollback)
compensate(ctx: ITransactionContext): Promise<void>;
}
```
### Execution Flow
```
Begin Transaction
┌─────────────────────┐
│ validate(op1) │──fail──► Return failure
└─────────────────────┘
│success
┌─────────────────────┐
│ execute(op1) │──fail──┐
└─────────────────────┘ │
│success │
▼ │
┌─────────────────────┐ │
│ validate(op2) │──fail──┤
└─────────────────────┘ │
│success │
▼ │
┌─────────────────────┐ │
│ execute(op2) │──fail──┤
└─────────────────────┘ │
│success ▼
▼ ┌─────────────────────┐
Commit Transaction │ compensate(op1) │
└─────────────────────┘
Return failure (rolled back)
```
### Custom Operations
```typescript
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
interface UpgradeData {
playerId: string;
itemId: string;
targetLevel: number;
}
interface UpgradeResult {
newLevel: number;
}
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
readonly name = 'upgrade';
private _previousLevel: number = 0;
async validate(ctx: ITransactionContext): Promise<boolean> {
// Validate item exists and can be upgraded
const item = await this.getItem(ctx);
return item !== null && item.level < this.data.targetLevel;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
const item = await this.getItem(ctx);
if (!item) {
return this.failure('Item not found', 'ITEM_NOT_FOUND');
}
this._previousLevel = item.level;
item.level = this.data.targetLevel;
await this.saveItem(ctx, item);
return this.success({ newLevel: item.level });
}
async compensate(ctx: ITransactionContext): Promise<void> {
const item = await this.getItem(ctx);
if (item) {
item.level = this._previousLevel;
await this.saveItem(ctx, item);
}
}
private async getItem(ctx: ITransactionContext) {
// Get item from storage
}
private async saveItem(ctx: ITransactionContext, item: any) {
// Save item to storage
}
}
```
## Transaction Result
```typescript
interface TransactionResult<T = unknown> {
success: boolean; // Whether succeeded
transactionId: string; // Transaction ID
results: OperationResult[]; // Operation results
data?: T; // Final data
error?: string; // Error message
duration: number; // Execution time (ms)
}
const result = await manager.run((tx) => { ... });
console.log(`Transaction ${result.transactionId}`);
console.log(`Success: ${result.success}`);
console.log(`Duration: ${result.duration}ms`);
if (!result.success) {
console.log(`Error: ${result.error}`);
}
```

View File

@@ -0,0 +1,355 @@
---
title: "Distributed Transactions"
description: "Saga orchestrator and cross-server transaction support"
---
## Saga Orchestrator
`SagaOrchestrator` is used to orchestrate distributed transactions across servers.
### Basic Usage
```typescript
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
const orchestrator = new SagaOrchestrator({
storage: new RedisStorage({ client: redis }),
timeout: 30000,
serverId: 'orchestrator-1',
});
const result = await orchestrator.execute([
{
name: 'deduct_currency',
serverId: 'game-server-1',
data: { playerId: 'player1', amount: 100 },
execute: async (data) => {
// Call game server API to deduct currency
const response = await gameServerApi.deductCurrency(data);
return { success: response.ok };
},
compensate: async (data) => {
// Call game server API to restore currency
await gameServerApi.addCurrency(data);
},
},
{
name: 'add_item',
serverId: 'inventory-server-1',
data: { playerId: 'player1', itemId: 'sword' },
execute: async (data) => {
const response = await inventoryServerApi.addItem(data);
return { success: response.ok };
},
compensate: async (data) => {
await inventoryServerApi.removeItem(data);
},
},
]);
if (result.success) {
console.log('Saga completed successfully');
} else {
console.log('Saga failed:', result.error);
console.log('Completed steps:', result.completedSteps);
console.log('Failed at:', result.failedStep);
}
```
### Configuration Options
```typescript
interface SagaOrchestratorConfig {
storage?: ITransactionStorage; // Storage instance
timeout?: number; // Timeout in milliseconds
serverId?: string; // Orchestrator server ID
}
```
### Saga Step
```typescript
interface SagaStep<T = unknown> {
name: string; // Step name
serverId?: string; // Target server ID
data: T; // Step data
execute: (data: T) => Promise<OperationResult>; // Execute function
compensate: (data: T) => Promise<void>; // Compensate function
}
```
### Saga Result
```typescript
interface SagaResult {
success: boolean; // Whether succeeded
sagaId: string; // Saga ID
completedSteps: string[]; // Completed steps
failedStep?: string; // Failed step
error?: string; // Error message
duration: number; // Execution time (ms)
}
```
## Execution Flow
```
Start Saga
┌─────────────────────┐
│ Step 1: execute │──fail──┐
└─────────────────────┘ │
│success │
▼ │
┌─────────────────────┐ │
│ Step 2: execute │──fail──┤
└─────────────────────┘ │
│success │
▼ │
┌─────────────────────┐ │
│ Step 3: execute │──fail──┤
└─────────────────────┘ │
│success ▼
▼ ┌─────────────────────┐
Saga Complete │ Step 2: compensate │
└─────────────────────┘
┌─────────────────────┐
│ Step 1: compensate │
└─────────────────────┘
Saga Failed (compensated)
```
## Saga Logs
The orchestrator records detailed execution logs:
```typescript
interface SagaLog {
id: string; // Saga ID
state: SagaLogState; // State
steps: SagaStepLog[]; // Step logs
createdAt: number; // Creation time
updatedAt: number; // Update time
metadata?: Record<string, unknown>;
}
type SagaLogState =
| 'pending' // Waiting to execute
| 'running' // Executing
| 'completed' // Completed
| 'compensating' // Compensating
| 'compensated' // Compensated
| 'failed' // Failed
interface SagaStepLog {
name: string; // Step name
serverId?: string; // Server ID
state: SagaStepState; // State
startedAt?: number; // Start time
completedAt?: number; // Completion time
error?: string; // Error message
}
type SagaStepState =
| 'pending' // Waiting to execute
| 'executing' // Executing
| 'completed' // Completed
| 'compensating' // Compensating
| 'compensated' // Compensated
| 'failed' // Failed
```
### Query Saga Logs
```typescript
const log = await orchestrator.getSagaLog('saga_xxx');
if (log) {
console.log('Saga state:', log.state);
for (const step of log.steps) {
console.log(` ${step.name}: ${step.state}`);
}
}
```
## Cross-Server Transaction Examples
### Scenario: Cross-Server Purchase
A player purchases an item on a game server, with currency on an account server and items on an inventory server.
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'purchase-orchestrator',
});
async function crossServerPurchase(
playerId: string,
itemId: string,
price: number
): Promise<SagaResult> {
return orchestrator.execute([
// Step 1: Deduct balance on account server
{
name: 'deduct_balance',
serverId: 'account-server',
data: { playerId, amount: price },
execute: async (data) => {
const result = await accountService.deduct(data.playerId, data.amount);
return { success: result.ok, error: result.error };
},
compensate: async (data) => {
await accountService.refund(data.playerId, data.amount);
},
},
// Step 2: Add item on inventory server
{
name: 'add_item',
serverId: 'inventory-server',
data: { playerId, itemId },
execute: async (data) => {
const result = await inventoryService.addItem(data.playerId, data.itemId);
return { success: result.ok, error: result.error };
},
compensate: async (data) => {
await inventoryService.removeItem(data.playerId, data.itemId);
},
},
// Step 3: Record purchase log
{
name: 'log_purchase',
serverId: 'log-server',
data: { playerId, itemId, price, timestamp: Date.now() },
execute: async (data) => {
await logService.recordPurchase(data);
return { success: true };
},
compensate: async (data) => {
await logService.cancelPurchase(data);
},
},
]);
}
```
### Scenario: Cross-Server Trade
Two players on different servers trade with each other.
```typescript
async function crossServerTrade(
playerA: { id: string; server: string; items: string[] },
playerB: { id: string; server: string; items: string[] }
): Promise<SagaResult> {
const steps: SagaStep[] = [];
// Remove items from player A
for (const itemId of playerA.items) {
steps.push({
name: `remove_${playerA.id}_${itemId}`,
serverId: playerA.server,
data: { playerId: playerA.id, itemId },
execute: async (data) => {
return await inventoryService.removeItem(data.playerId, data.itemId);
},
compensate: async (data) => {
await inventoryService.addItem(data.playerId, data.itemId);
},
});
}
// Add items to player B (from A)
for (const itemId of playerA.items) {
steps.push({
name: `add_${playerB.id}_${itemId}`,
serverId: playerB.server,
data: { playerId: playerB.id, itemId },
execute: async (data) => {
return await inventoryService.addItem(data.playerId, data.itemId);
},
compensate: async (data) => {
await inventoryService.removeItem(data.playerId, data.itemId);
},
});
}
// Similarly handle player B's items...
return orchestrator.execute(steps);
}
```
## Recovering Incomplete Sagas
Recover incomplete Sagas after server restart:
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'my-orchestrator',
});
// Recover incomplete Sagas (will execute compensation)
const recoveredCount = await orchestrator.recover();
console.log(`Recovered ${recoveredCount} sagas`);
```
## Best Practices
### 1. Idempotency
Ensure all operations are idempotent:
```typescript
{
execute: async (data) => {
// Use unique ID to ensure idempotency
const result = await service.process(data.requestId, data);
return { success: result.ok };
},
compensate: async (data) => {
// Compensation must also be idempotent
await service.rollback(data.requestId);
},
}
```
### 2. Timeout Handling
Set appropriate timeout values:
```typescript
const orchestrator = new SagaOrchestrator({
timeout: 60000, // Cross-server operations need longer timeout
});
```
### 3. Monitoring and Alerts
Log Saga execution results:
```typescript
const result = await orchestrator.execute(steps);
if (!result.success) {
// Send alert
alertService.send({
type: 'saga_failed',
sagaId: result.sagaId,
failedStep: result.failedStep,
error: result.error,
});
// Log details
const log = await orchestrator.getSagaLog(result.sagaId);
logger.error('Saga failed', { log });
}
```

View File

@@ -0,0 +1,238 @@
---
title: "Transaction System"
description: "Game transaction system with distributed support for shop purchases, player trading, and more"
---
`@esengine/transaction` provides comprehensive game transaction capabilities based on the Saga pattern, supporting shop purchases, player trading, multi-step tasks, and distributed transactions with Redis/MongoDB.
## Overview
The transaction system solves common data consistency problems in games:
| Scenario | Problem | Solution |
|----------|---------|----------|
| Shop Purchase | Payment succeeded but item not delivered | Atomic transaction with auto-rollback |
| Player Trade | One party transferred items but other didn't receive | Saga compensation mechanism |
| Cross-Server | Data inconsistency across servers | Distributed lock + transaction log |
## Installation
```bash
npm install @esengine/transaction
```
Optional dependencies (install based on storage needs):
```bash
npm install ioredis # Redis storage
npm install mongodb # MongoDB storage
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Transaction Layer │
├─────────────────────────────────────────────────────────────────┤
│ TransactionManager - Manages transaction lifecycle │
│ TransactionContext - Encapsulates operations and state │
│ SagaOrchestrator - Distributed Saga orchestrator │
├─────────────────────────────────────────────────────────────────┤
│ Storage Layer │
├─────────────────────────────────────────────────────────────────┤
│ MemoryStorage - In-memory (dev/test) │
│ RedisStorage - Redis (distributed lock + cache) │
│ MongoStorage - MongoDB (persistent log) │
├─────────────────────────────────────────────────────────────────┤
│ Operation Layer │
├─────────────────────────────────────────────────────────────────┤
│ CurrencyOperation - Currency operations │
│ InventoryOperation - Inventory operations │
│ TradeOperation - Trade operations │
└─────────────────────────────────────────────────────────────────┘
```
## Quick Start
### Basic Usage
```typescript
import {
TransactionManager,
MemoryStorage,
CurrencyOperation,
InventoryOperation,
} from '@esengine/transaction';
// Create transaction manager
const manager = new TransactionManager({
storage: new MemoryStorage(),
defaultTimeout: 10000,
});
// Execute transaction
const result = await manager.run((tx) => {
// Deduct gold
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
}));
// Add item
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
}));
});
if (result.success) {
console.log('Purchase successful!');
} else {
console.log('Purchase failed:', result.error);
}
```
### Player Trading
```typescript
import { TradeOperation } from '@esengine/transaction';
const result = await manager.run((tx) => {
tx.addOperation(new TradeOperation({
tradeId: 'trade_001',
partyA: {
playerId: 'player1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
}));
}, { timeout: 30000 });
```
### Using Redis Storage
```typescript
import Redis from 'ioredis';
import { TransactionManager, RedisStorage } from '@esengine/transaction';
const redis = new Redis('redis://localhost:6379');
const storage = new RedisStorage({ client: redis });
const manager = new TransactionManager({ storage });
```
### Using MongoDB Storage
```typescript
import { MongoClient } from 'mongodb';
import { TransactionManager, MongoStorage } from '@esengine/transaction';
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
const db = client.db('game');
const storage = new MongoStorage({ db });
await storage.ensureIndexes();
const manager = new TransactionManager({ storage });
```
## Room Integration
```typescript
import { Room } from '@esengine/server';
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
class GameRoom extends withTransactions(Room, {
storage: new RedisStorage({ client: redisClient }),
}) {
@onMessage('Buy')
async handleBuy(data: { itemId: string }, player: Player) {
const result = await this.runTransaction((tx) => {
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: player.id,
currency: 'gold',
amount: getItemPrice(data.itemId),
}));
});
if (result.success) {
player.send('buy_success', { itemId: data.itemId });
} else {
player.send('buy_failed', { error: result.error });
}
}
}
```
## Documentation
- [Core Concepts](/en/modules/transaction/core/) - Transaction context, manager, Saga pattern
- [Storage Layer](/en/modules/transaction/storage/) - MemoryStorage, RedisStorage, MongoStorage
- [Operations](/en/modules/transaction/operations/) - Currency, inventory, trade operations
- [Distributed Transactions](/en/modules/transaction/distributed/) - Saga orchestrator, cross-server transactions
- [API Reference](/en/modules/transaction/api/) - Complete API documentation
## Service Tokens
For dependency injection:
```typescript
import {
TransactionManagerToken,
TransactionStorageToken,
} from '@esengine/transaction';
const manager = services.get(TransactionManagerToken);
```
## Best Practices
### 1. Operation Granularity
```typescript
// ✅ Good: Fine-grained operations, easy to rollback
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
// ❌ Bad: Coarse-grained operation, hard to partially rollback
tx.addOperation(new ComplexPurchaseOperation({ ... }));
```
### 2. Timeout Settings
```typescript
// Simple operations: short timeout
await manager.run(tx => { ... }, { timeout: 5000 });
// Complex trades: longer timeout
await manager.run(tx => { ... }, { timeout: 30000 });
// Cross-server: even longer timeout
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
```
### 3. Error Handling
```typescript
const result = await manager.run((tx) => { ... });
if (!result.success) {
// Log the error
logger.error('Transaction failed', {
transactionId: result.transactionId,
error: result.error,
duration: result.duration,
});
// Notify user
player.send('error', { message: getErrorMessage(result.error) });
}
```

View File

@@ -0,0 +1,313 @@
---
title: "Operations"
description: "Built-in transaction operations: currency, inventory, trade"
---
## BaseOperation
Base class for all operations, providing a common implementation template.
```typescript
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
class MyOperation extends BaseOperation<MyData, MyResult> {
readonly name = 'myOperation';
async validate(ctx: ITransactionContext): Promise<boolean> {
// Validate preconditions
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// Execute operation
return this.success({ result: 'ok' });
// or
return this.failure('Something went wrong', 'ERROR_CODE');
}
async compensate(ctx: ITransactionContext): Promise<void> {
// Rollback operation
}
}
```
## CurrencyOperation
Handles currency addition and deduction.
### Deduct Currency
```typescript
import { CurrencyOperation } from '@esengine/transaction';
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
reason: 'purchase_item',
}));
```
### Add Currency
```typescript
tx.addOperation(new CurrencyOperation({
type: 'add',
playerId: 'player1',
currency: 'diamond',
amount: 50,
reason: 'daily_reward',
}));
```
### Operation Data
```typescript
interface CurrencyOperationData {
type: 'add' | 'deduct'; // Operation type
playerId: string; // Player ID
currency: string; // Currency type
amount: number; // Amount
reason?: string; // Reason/source
}
```
### Operation Result
```typescript
interface CurrencyOperationResult {
beforeBalance: number; // Balance before operation
afterBalance: number; // Balance after operation
}
```
### Custom Data Provider
```typescript
interface ICurrencyProvider {
getBalance(playerId: string, currency: string): Promise<number>;
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
}
class MyCurrencyProvider implements ICurrencyProvider {
async getBalance(playerId: string, currency: string): Promise<number> {
// Get balance from database
return await db.getCurrency(playerId, currency);
}
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
// Save to database
await db.setCurrency(playerId, currency, amount);
}
}
// Use custom provider
const op = new CurrencyOperation({ ... });
op.setProvider(new MyCurrencyProvider());
tx.addOperation(op);
```
## InventoryOperation
Handles item addition, removal, and updates.
### Add Item
```typescript
import { InventoryOperation } from '@esengine/transaction';
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
properties: { enchant: 'fire' },
}));
```
### Remove Item
```typescript
tx.addOperation(new InventoryOperation({
type: 'remove',
playerId: 'player1',
itemId: 'potion_hp',
quantity: 5,
}));
```
### Update Item
```typescript
tx.addOperation(new InventoryOperation({
type: 'update',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1, // Optional, keeps original if not provided
properties: { enchant: 'lightning', level: 5 },
}));
```
### Operation Data
```typescript
interface InventoryOperationData {
type: 'add' | 'remove' | 'update'; // Operation type
playerId: string; // Player ID
itemId: string; // Item ID
quantity: number; // Quantity
properties?: Record<string, unknown>; // Item properties
reason?: string; // Reason/source
}
```
### Operation Result
```typescript
interface InventoryOperationResult {
beforeItem?: ItemData; // Item before operation
afterItem?: ItemData; // Item after operation
}
interface ItemData {
itemId: string;
quantity: number;
properties?: Record<string, unknown>;
}
```
### Custom Data Provider
```typescript
interface IInventoryProvider {
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
hasCapacity?(playerId: string, count: number): Promise<boolean>;
}
class MyInventoryProvider implements IInventoryProvider {
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
return await db.getItem(playerId, itemId);
}
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
if (item) {
await db.saveItem(playerId, itemId, item);
} else {
await db.deleteItem(playerId, itemId);
}
}
async hasCapacity(playerId: string, count: number): Promise<boolean> {
const current = await db.getItemCount(playerId);
const max = await db.getMaxCapacity(playerId);
return current + count <= max;
}
}
```
## TradeOperation
Handles item and currency exchange between players.
### Basic Usage
```typescript
import { TradeOperation } from '@esengine/transaction';
tx.addOperation(new TradeOperation({
tradeId: 'trade_001',
partyA: {
playerId: 'player1',
items: [{ itemId: 'sword', quantity: 1 }],
currencies: [{ currency: 'diamond', amount: 10 }],
},
partyB: {
playerId: 'player2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
reason: 'player_trade',
}));
```
### Operation Data
```typescript
interface TradeOperationData {
tradeId: string; // Trade ID
partyA: TradeParty; // Trade initiator
partyB: TradeParty; // Trade receiver
reason?: string; // Reason/note
}
interface TradeParty {
playerId: string; // Player ID
items?: TradeItem[]; // Items to give
currencies?: TradeCurrency[]; // Currencies to give
}
interface TradeItem {
itemId: string;
quantity: number;
}
interface TradeCurrency {
currency: string;
amount: number;
}
```
### Execution Flow
TradeOperation internally generates the following sub-operation sequence:
```
1. Remove partyA's items
2. Add items to partyB (from partyA)
3. Deduct partyA's currencies
4. Add currencies to partyB (from partyA)
5. Remove partyB's items
6. Add items to partyA (from partyB)
7. Deduct partyB's currencies
8. Add currencies to partyA (from partyB)
```
If any step fails, all previous operations are rolled back.
### Using Custom Providers
```typescript
const op = new TradeOperation({ ... });
op.setProvider({
currencyProvider: new MyCurrencyProvider(),
inventoryProvider: new MyInventoryProvider(),
});
tx.addOperation(op);
```
## Factory Functions
Each operation class provides a factory function:
```typescript
import {
createCurrencyOperation,
createInventoryOperation,
createTradeOperation,
} from '@esengine/transaction';
tx.addOperation(createCurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
}));
tx.addOperation(createInventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword',
quantity: 1,
}));
```

View File

@@ -0,0 +1,234 @@
---
title: "Storage Layer"
description: "Transaction storage interface and implementations: MemoryStorage, RedisStorage, MongoStorage"
---
## Storage Interface
All storage implementations must implement the `ITransactionStorage` interface:
```typescript
interface ITransactionStorage {
// Lifecycle
close?(): Promise<void>;
// Distributed lock
acquireLock(key: string, ttl: number): Promise<string | null>;
releaseLock(key: string, token: string): Promise<boolean>;
// Transaction log
saveTransaction(tx: TransactionLog): Promise<void>;
getTransaction(id: string): Promise<TransactionLog | null>;
updateTransactionState(id: string, state: TransactionState): Promise<void>;
updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise<void>;
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>;
deleteTransaction(id: string): Promise<void>;
// Data operations
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<boolean>;
}
```
## MemoryStorage
In-memory storage, suitable for development and testing.
```typescript
import { MemoryStorage } from '@esengine/transaction';
const storage = new MemoryStorage({
maxTransactions: 1000, // Maximum transaction log count
});
const manager = new TransactionManager({ storage });
```
### Characteristics
- ✅ No external dependencies
- ✅ Fast, good for debugging
- ❌ Data only stored in memory
- ❌ No true distributed locking
- ❌ Data lost on restart
### Test Helpers
```typescript
// Clear all data
storage.clear();
// Get transaction count
console.log(storage.transactionCount);
```
## RedisStorage
Redis storage, suitable for production distributed systems. Uses factory pattern with lazy connection.
```typescript
import Redis from 'ioredis';
import { RedisStorage } from '@esengine/transaction';
// Factory pattern: lazy connection, connects on first operation
const storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'tx:', // Key prefix
transactionTTL: 86400, // Transaction log TTL (seconds)
});
const manager = new TransactionManager({ storage });
// Close connection when done
await storage.close();
// Or use await using for automatic cleanup (TypeScript 5.2+)
await using storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379')
});
// Automatically closed when scope ends
```
### Characteristics
- ✅ High-performance distributed locking
- ✅ Fast read/write
- ✅ Supports TTL auto-expiration
- ✅ Suitable for high concurrency
- ❌ Requires Redis server
### Distributed Lock Implementation
Uses Redis `SET NX EX` for distributed locking:
```typescript
// Acquire lock (atomic operation)
SET tx:lock:player:123 <token> NX EX 10
// Release lock (Lua script for atomicity)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
```
### Key Structure
```
tx:lock:{key} - Distributed locks
tx:tx:{id} - Transaction logs
tx:server:{id}:txs - Server transaction index
tx:data:{key} - Business data
```
## MongoStorage
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses factory pattern with lazy connection.
```typescript
import { MongoClient } from 'mongodb';
import { MongoStorage } from '@esengine/transaction';
// Factory pattern: lazy connection, connects on first operation
const storage = new MongoStorage({
factory: async () => {
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
return client;
},
database: 'game',
transactionCollection: 'transactions', // Transaction log collection
dataCollection: 'transaction_data', // Business data collection
lockCollection: 'transaction_locks', // Lock collection
});
// Create indexes (run on first startup)
await storage.ensureIndexes();
const manager = new TransactionManager({ storage });
// Close connection when done
await storage.close();
// Or use await using for automatic cleanup (TypeScript 5.2+)
await using storage = new MongoStorage({ ... });
```
### Characteristics
- ✅ Persistent storage
- ✅ Supports complex queries
- ✅ Transaction logs are traceable
- ✅ Suitable for audit requirements
- ❌ Slightly lower performance than Redis
- ❌ Requires MongoDB server
### Index Structure
```javascript
// transactions collection
{ state: 1 }
{ 'metadata.serverId': 1 }
{ createdAt: 1 }
// transaction_locks collection
{ expireAt: 1 } // TTL index
// transaction_data collection
{ expireAt: 1 } // TTL index
```
### Distributed Lock Implementation
Uses MongoDB unique index for distributed locking:
```typescript
// Acquire lock
db.transaction_locks.insertOne({
_id: 'player:123',
token: '<token>',
expireAt: new Date(Date.now() + 10000)
});
// If key exists, check if expired
db.transaction_locks.updateOne(
{ _id: 'player:123', expireAt: { $lt: new Date() } },
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
);
```
## Storage Selection Guide
| Scenario | Recommended Storage | Reason |
|----------|---------------------|--------|
| Development/Testing | MemoryStorage | No dependencies, fast startup |
| Single-machine Production | RedisStorage | High performance, simple |
| Distributed System | RedisStorage | True distributed locking |
| Audit Required | MongoStorage | Persistent logs |
| Mixed Requirements | Redis + Mongo | Redis for locks, Mongo for logs |
## Custom Storage
Implement `ITransactionStorage` interface to create custom storage:
```typescript
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
class MyCustomStorage implements ITransactionStorage {
async acquireLock(key: string, ttl: number): Promise<string | null> {
// Implement distributed lock acquisition
}
async releaseLock(key: string, token: string): Promise<boolean> {
// Implement distributed lock release
}
async saveTransaction(tx: TransactionLog): Promise<void> {
// Save transaction log
}
// ... implement other methods
}
```

View File

@@ -35,6 +35,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
| 模块 | 包名 | 描述 |
|------|------|------|
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
## 安装

View 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` 测试认证场景。

View 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} 上被限流`)
}
}
```

View File

@@ -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 频率**

View File

@@ -1,8 +1,176 @@
---
title: "状态同步"
description: "插值、预测和快照缓冲区"
description: "组件同步、插值、预测和快照缓冲区"
---
## @NetworkEntity 装饰器
`@NetworkEntity` 装饰器用于标记需要自动广播生成/销毁的组件。当包含此组件的实体被创建或销毁时ECSRoom 会自动广播相应的消息给所有客户端。
### 基本用法
```typescript
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
@ECSComponent('Enemy')
@NetworkEntity('Enemy')
class EnemyComponent extends Component {
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
@sync('uint16') health: number = 100;
}
```
当添加此组件到实体时ECSRoom 会自动广播 spawn 消息:
```typescript
// 服务端
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
// 销毁时自动广播 despawn
entity.destroy(); // 自动广播 despawn
```
### 配置选项
```typescript
@NetworkEntity('Bullet', {
autoSpawn: true, // 自动广播生成(默认 true
autoDespawn: false // 禁用自动广播销毁
})
class BulletComponent extends Component { }
```
| 选项 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `autoSpawn` | `boolean` | `true` | 添加组件时自动广播 spawn |
| `autoDespawn` | `boolean` | `true` | 销毁实体时自动广播 despawn |
### 初始化顺序
使用 `@NetworkEntity` 时,应在添加组件**之前**初始化数据:
```typescript
// ✅ 正确:先初始化,再添加
const comp = new PlayerComponent();
comp.playerId = player.id;
comp.x = 100;
comp.y = 200;
entity.addComponent(comp); // spawn 时数据已正确
// ❌ 错误:先添加,再初始化
const comp = entity.addComponent(new PlayerComponent());
comp.playerId = player.id; // spawn 时数据是默认值
```
### 简化 GameRoom
使用 `@NetworkEntity`GameRoom 变得更加简洁:
```typescript
// 无需手动回调
class GameRoom extends ECSRoom {
private setupSystems(): void {
// 敌人生成系统(自动广播 spawn
this.addSystem(new EnemySpawnSystem());
// 敌人 AI 系统
const enemyAI = new EnemyAISystem();
enemyAI.onDeath((enemy) => {
enemy.destroy(); // 自动广播 despawn
});
this.addSystem(enemyAI);
}
}
```
### ECSRoom 配置
可以在 ECSRoom 中禁用自动网络实体功能:
```typescript
class GameRoom extends ECSRoom {
constructor() {
super({
enableAutoNetworkEntity: false // 禁用自动广播
});
}
}
```
## 组件同步系统
基于 `@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"` | 字符串 | 变长 |
## 快照缓冲区
用于存储服务器状态快照并进行插值:

View File

@@ -0,0 +1,261 @@
---
title: "核心概念"
description: "事务系统的核心概念事务上下文、事务管理器、Saga 模式"
---
## 事务状态
事务有以下几种状态:
```typescript
type TransactionState =
| 'pending' // 等待执行
| 'executing' // 执行中
| 'committed' // 已提交
| 'rolledback' // 已回滚
| 'failed' // 失败
```
## TransactionContext
事务上下文封装了事务的状态、操作和执行逻辑。
### 创建事务
```typescript
import { TransactionManager } from '@esengine/transaction';
const manager = new TransactionManager();
// 方式 1使用 begin() 手动管理
const tx = manager.begin({ timeout: 5000 });
tx.addOperation(op1);
tx.addOperation(op2);
const result = await tx.execute();
// 方式 2使用 run() 自动管理
const result = await manager.run((tx) => {
tx.addOperation(op1);
tx.addOperation(op2);
});
```
### 链式添加操作
```typescript
const result = await manager.run((tx) => {
tx.addOperation(new CurrencyOperation({ ... }))
.addOperation(new InventoryOperation({ ... }))
.addOperation(new InventoryOperation({ ... }));
});
```
### 上下文数据
操作之间可以通过上下文共享数据:
```typescript
class CustomOperation extends BaseOperation<MyData, MyResult> {
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// 读取之前操作设置的数据
const previousResult = ctx.get<number>('previousValue');
// 设置数据供后续操作使用
ctx.set('myResult', { value: 123 });
return this.success({ ... });
}
}
```
## TransactionManager
事务管理器负责创建、执行和恢复事务。
### 配置选项
```typescript
interface TransactionManagerConfig {
storage?: ITransactionStorage; // 存储实例
defaultTimeout?: number; // 默认超时(毫秒)
serverId?: string; // 服务器 ID分布式用
autoRecover?: boolean; // 自动恢复未完成事务
}
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
defaultTimeout: 10000,
serverId: 'server-1',
autoRecover: true,
});
```
### 分布式锁
```typescript
// 获取锁
const token = await manager.acquireLock('player:123:inventory', 10000);
if (token) {
try {
// 执行操作
await doSomething();
} finally {
// 释放锁
await manager.releaseLock('player:123:inventory', token);
}
}
// 或使用 withLock 简化
await manager.withLock('player:123:inventory', async () => {
await doSomething();
}, 10000);
```
### 事务恢复
服务器重启时恢复未完成的事务:
```typescript
const manager = new TransactionManager({
storage: new RedisStorage({ client: redis }),
serverId: 'server-1',
});
// 恢复未完成的事务
const recoveredCount = await manager.recover();
console.log(`Recovered ${recoveredCount} transactions`);
```
## Saga 模式
事务系统采用 Saga 模式,每个操作必须实现 `execute``compensate` 方法:
```typescript
interface ITransactionOperation<TData, TResult> {
readonly name: string;
readonly data: TData;
// 验证前置条件
validate(ctx: ITransactionContext): Promise<boolean>;
// 正向执行
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
// 补偿操作(回滚)
compensate(ctx: ITransactionContext): Promise<void>;
}
```
### 执行流程
```
开始事务
┌─────────────────────┐
│ validate(op1) │──失败──► 返回失败
└─────────────────────┘
│成功
┌─────────────────────┐
│ execute(op1) │──失败──┐
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ validate(op2) │──失败──┤
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ execute(op2) │──失败──┤
└─────────────────────┘ │
│成功 ▼
▼ ┌─────────────────────┐
提交事务 │ compensate(op1) │
└─────────────────────┘
返回失败(已回滚)
```
### 自定义操作
```typescript
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
interface UpgradeData {
playerId: string;
itemId: string;
targetLevel: number;
}
interface UpgradeResult {
newLevel: number;
}
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
readonly name = 'upgrade';
private _previousLevel: number = 0;
async validate(ctx: ITransactionContext): Promise<boolean> {
// 验证物品存在且可升级
const item = await this.getItem(ctx);
return item !== null && item.level < this.data.targetLevel;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
const item = await this.getItem(ctx);
if (!item) {
return this.failure('Item not found', 'ITEM_NOT_FOUND');
}
this._previousLevel = item.level;
item.level = this.data.targetLevel;
await this.saveItem(ctx, item);
return this.success({ newLevel: item.level });
}
async compensate(ctx: ITransactionContext): Promise<void> {
const item = await this.getItem(ctx);
if (item) {
item.level = this._previousLevel;
await this.saveItem(ctx, item);
}
}
private async getItem(ctx: ITransactionContext) {
// 从存储获取物品
}
private async saveItem(ctx: ITransactionContext, item: any) {
// 保存物品到存储
}
}
```
## 事务结果
```typescript
interface TransactionResult<T = unknown> {
success: boolean; // 是否成功
transactionId: string; // 事务 ID
results: OperationResult[]; // 各操作结果
data?: T; // 最终数据
error?: string; // 错误信息
duration: number; // 执行时间(毫秒)
}
const result = await manager.run((tx) => { ... });
console.log(`Transaction ${result.transactionId}`);
console.log(`Success: ${result.success}`);
console.log(`Duration: ${result.duration}ms`);
if (!result.success) {
console.log(`Error: ${result.error}`);
}
```

View File

@@ -0,0 +1,355 @@
---
title: "分布式事务"
description: "Saga 编排器和跨服务器事务支持"
---
## Saga 编排器
`SagaOrchestrator` 用于编排跨服务器的分布式事务。
### 基本用法
```typescript
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
const orchestrator = new SagaOrchestrator({
storage: new RedisStorage({ client: redis }),
timeout: 30000,
serverId: 'orchestrator-1',
});
const result = await orchestrator.execute([
{
name: 'deduct_currency',
serverId: 'game-server-1',
data: { playerId: 'player1', amount: 100 },
execute: async (data) => {
// 调用游戏服务器 API 扣除货币
const response = await gameServerApi.deductCurrency(data);
return { success: response.ok };
},
compensate: async (data) => {
// 调用游戏服务器 API 恢复货币
await gameServerApi.addCurrency(data);
},
},
{
name: 'add_item',
serverId: 'inventory-server-1',
data: { playerId: 'player1', itemId: 'sword' },
execute: async (data) => {
const response = await inventoryServerApi.addItem(data);
return { success: response.ok };
},
compensate: async (data) => {
await inventoryServerApi.removeItem(data);
},
},
]);
if (result.success) {
console.log('Saga completed successfully');
} else {
console.log('Saga failed:', result.error);
console.log('Completed steps:', result.completedSteps);
console.log('Failed at:', result.failedStep);
}
```
### 配置选项
```typescript
interface SagaOrchestratorConfig {
storage?: ITransactionStorage; // 存储实例
timeout?: number; // 超时时间(毫秒)
serverId?: string; // 编排器服务器 ID
}
```
### Saga 步骤
```typescript
interface SagaStep<T = unknown> {
name: string; // 步骤名称
serverId?: string; // 目标服务器 ID
data: T; // 步骤数据
execute: (data: T) => Promise<OperationResult>; // 执行函数
compensate: (data: T) => Promise<void>; // 补偿函数
}
```
### Saga 结果
```typescript
interface SagaResult {
success: boolean; // 是否成功
sagaId: string; // Saga ID
completedSteps: string[]; // 已完成的步骤
failedStep?: string; // 失败的步骤
error?: string; // 错误信息
duration: number; // 执行时间(毫秒)
}
```
## 执行流程
```
开始 Saga
┌─────────────────────┐
│ Step 1: execute │──失败──┐
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ Step 2: execute │──失败──┤
└─────────────────────┘ │
│成功 │
▼ │
┌─────────────────────┐ │
│ Step 3: execute │──失败──┤
└─────────────────────┘ │
│成功 ▼
▼ ┌─────────────────────┐
Saga 完成 │ Step 2: compensate │
└─────────────────────┘
┌─────────────────────┐
│ Step 1: compensate │
└─────────────────────┘
Saga 失败(已补偿)
```
## Saga 日志
编排器会记录详细的执行日志:
```typescript
interface SagaLog {
id: string; // Saga ID
state: SagaLogState; // 状态
steps: SagaStepLog[]; // 步骤日志
createdAt: number; // 创建时间
updatedAt: number; // 更新时间
metadata?: Record<string, unknown>;
}
type SagaLogState =
| 'pending' // 等待执行
| 'running' // 执行中
| 'completed' // 已完成
| 'compensating' // 补偿中
| 'compensated' // 已补偿
| 'failed' // 失败
interface SagaStepLog {
name: string; // 步骤名称
serverId?: string; // 服务器 ID
state: SagaStepState; // 状态
startedAt?: number; // 开始时间
completedAt?: number; // 完成时间
error?: string; // 错误信息
}
type SagaStepState =
| 'pending' // 等待执行
| 'executing' // 执行中
| 'completed' // 已完成
| 'compensating' // 补偿中
| 'compensated' // 已补偿
| 'failed' // 失败
```
### 查询 Saga 日志
```typescript
const log = await orchestrator.getSagaLog('saga_xxx');
if (log) {
console.log('Saga state:', log.state);
for (const step of log.steps) {
console.log(` ${step.name}: ${step.state}`);
}
}
```
## 跨服务器事务示例
### 场景:跨服购买
玩家在游戏服务器购买物品,货币在账户服务器,物品在背包服务器。
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'purchase-orchestrator',
});
async function crossServerPurchase(
playerId: string,
itemId: string,
price: number
): Promise<SagaResult> {
return orchestrator.execute([
// 步骤 1在账户服务器扣款
{
name: 'deduct_balance',
serverId: 'account-server',
data: { playerId, amount: price },
execute: async (data) => {
const result = await accountService.deduct(data.playerId, data.amount);
return { success: result.ok, error: result.error };
},
compensate: async (data) => {
await accountService.refund(data.playerId, data.amount);
},
},
// 步骤 2在背包服务器添加物品
{
name: 'add_item',
serverId: 'inventory-server',
data: { playerId, itemId },
execute: async (data) => {
const result = await inventoryService.addItem(data.playerId, data.itemId);
return { success: result.ok, error: result.error };
},
compensate: async (data) => {
await inventoryService.removeItem(data.playerId, data.itemId);
},
},
// 步骤 3记录购买日志
{
name: 'log_purchase',
serverId: 'log-server',
data: { playerId, itemId, price, timestamp: Date.now() },
execute: async (data) => {
await logService.recordPurchase(data);
return { success: true };
},
compensate: async (data) => {
await logService.cancelPurchase(data);
},
},
]);
}
```
### 场景:跨服交易
两个玩家在不同服务器上进行交易。
```typescript
async function crossServerTrade(
playerA: { id: string; server: string; items: string[] },
playerB: { id: string; server: string; items: string[] }
): Promise<SagaResult> {
const steps: SagaStep[] = [];
// 移除 A 的物品
for (const itemId of playerA.items) {
steps.push({
name: `remove_${playerA.id}_${itemId}`,
serverId: playerA.server,
data: { playerId: playerA.id, itemId },
execute: async (data) => {
return await inventoryService.removeItem(data.playerId, data.itemId);
},
compensate: async (data) => {
await inventoryService.addItem(data.playerId, data.itemId);
},
});
}
// 添加物品到 B
for (const itemId of playerA.items) {
steps.push({
name: `add_${playerB.id}_${itemId}`,
serverId: playerB.server,
data: { playerId: playerB.id, itemId },
execute: async (data) => {
return await inventoryService.addItem(data.playerId, data.itemId);
},
compensate: async (data) => {
await inventoryService.removeItem(data.playerId, data.itemId);
},
});
}
// 类似地处理 B 的物品...
return orchestrator.execute(steps);
}
```
## 恢复未完成的 Saga
服务器重启后恢复未完成的 Saga
```typescript
const orchestrator = new SagaOrchestrator({
storage: redisStorage,
serverId: 'my-orchestrator',
});
// 恢复未完成的 Saga会执行补偿
const recoveredCount = await orchestrator.recover();
console.log(`Recovered ${recoveredCount} sagas`);
```
## 最佳实践
### 1. 幂等性
确保所有操作都是幂等的:
```typescript
{
execute: async (data) => {
// 使用唯一 ID 确保幂等
const result = await service.process(data.requestId, data);
return { success: result.ok };
},
compensate: async (data) => {
// 补偿也要幂等
await service.rollback(data.requestId);
},
}
```
### 2. 超时处理
设置合适的超时时间:
```typescript
const orchestrator = new SagaOrchestrator({
timeout: 60000, // 跨服务器操作需要更长超时
});
```
### 3. 监控和告警
记录 Saga 执行结果:
```typescript
const result = await orchestrator.execute(steps);
if (!result.success) {
// 发送告警
alertService.send({
type: 'saga_failed',
sagaId: result.sagaId,
failedStep: result.failedStep,
error: result.error,
});
// 记录详细日志
const log = await orchestrator.getSagaLog(result.sagaId);
logger.error('Saga failed', { log });
}
```

View File

@@ -0,0 +1,238 @@
---
title: "事务系统 (Transaction)"
description: "游戏事务处理系统,支持商店购买、玩家交易、分布式事务"
---
`@esengine/transaction` 提供完整的游戏事务处理能力,基于 Saga 模式实现,支持商店购买、玩家交易、多步骤任务等场景,并提供 Redis/MongoDB 分布式事务支持。
## 概述
事务系统解决游戏中常见的数据一致性问题:
| 场景 | 问题 | 解决方案 |
|------|------|----------|
| 商店购买 | 扣款成功但物品未发放 | 原子事务,失败自动回滚 |
| 玩家交易 | 一方物品转移另一方未收到 | Saga 补偿机制 |
| 跨服操作 | 多服务器数据不一致 | 分布式锁 + 事务日志 |
## 安装
```bash
npm install @esengine/transaction
```
可选依赖(根据存储需求安装):
```bash
npm install ioredis # Redis 存储
npm install mongodb # MongoDB 存储
```
## 架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Transaction Layer │
├─────────────────────────────────────────────────────────────────┤
│ TransactionManager - 事务管理器,协调事务生命周期 │
│ TransactionContext - 事务上下文,封装操作和状态 │
│ SagaOrchestrator - 分布式 Saga 编排器 │
├─────────────────────────────────────────────────────────────────┤
│ Storage Layer │
├─────────────────────────────────────────────────────────────────┤
│ MemoryStorage - 内存存储(开发/测试) │
│ RedisStorage - Redis分布式锁 + 缓存) │
│ MongoStorage - MongoDB持久化日志
├─────────────────────────────────────────────────────────────────┤
│ Operation Layer │
├─────────────────────────────────────────────────────────────────┤
│ CurrencyOperation - 货币操作 │
│ InventoryOperation - 背包操作 │
│ TradeOperation - 交易操作 │
└─────────────────────────────────────────────────────────────────┘
```
## 快速开始
### 基础用法
```typescript
import {
TransactionManager,
MemoryStorage,
CurrencyOperation,
InventoryOperation,
} from '@esengine/transaction';
// 创建事务管理器
const manager = new TransactionManager({
storage: new MemoryStorage(),
defaultTimeout: 10000,
});
// 执行事务
const result = await manager.run((tx) => {
// 扣除金币
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
}));
// 添加物品
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
}));
});
if (result.success) {
console.log('购买成功!');
} else {
console.log('购买失败:', result.error);
}
```
### 玩家交易
```typescript
import { TradeOperation } from '@esengine/transaction';
const result = await manager.run((tx) => {
tx.addOperation(new TradeOperation({
tradeId: 'trade_001',
partyA: {
playerId: 'player1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
}));
}, { timeout: 30000 });
```
### 使用 Redis 存储
```typescript
import Redis from 'ioredis';
import { TransactionManager, RedisStorage } from '@esengine/transaction';
const redis = new Redis('redis://localhost:6379');
const storage = new RedisStorage({ client: redis });
const manager = new TransactionManager({ storage });
```
### 使用 MongoDB 存储
```typescript
import { MongoClient } from 'mongodb';
import { TransactionManager, MongoStorage } from '@esengine/transaction';
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
const db = client.db('game');
const storage = new MongoStorage({ db });
await storage.ensureIndexes();
const manager = new TransactionManager({ storage });
```
## 与 Room 集成
```typescript
import { Room } from '@esengine/server';
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
class GameRoom extends withTransactions(Room, {
storage: new RedisStorage({ client: redisClient }),
}) {
@onMessage('Buy')
async handleBuy(data: { itemId: string }, player: Player) {
const result = await this.runTransaction((tx) => {
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: player.id,
currency: 'gold',
amount: getItemPrice(data.itemId),
}));
});
if (result.success) {
player.send('buy_success', { itemId: data.itemId });
} else {
player.send('buy_failed', { error: result.error });
}
}
}
```
## 文档导航
- [核心概念](/modules/transaction/core/) - 事务上下文、管理器、Saga 模式
- [存储层](/modules/transaction/storage/) - MemoryStorage、RedisStorage、MongoStorage
- [操作类](/modules/transaction/operations/) - 货币、背包、交易操作
- [分布式事务](/modules/transaction/distributed/) - Saga 编排器、跨服务器事务
- [API 参考](/modules/transaction/api/) - 完整 API 文档
## 服务令牌
用于依赖注入:
```typescript
import {
TransactionManagerToken,
TransactionStorageToken,
} from '@esengine/transaction';
const manager = services.get(TransactionManagerToken);
```
## 最佳实践
### 1. 操作粒度
```typescript
// ✅ 好:细粒度操作,便于回滚
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
// ❌ 差:粗粒度操作,难以部分回滚
tx.addOperation(new ComplexPurchaseOperation({ ... }));
```
### 2. 超时设置
```typescript
// 简单操作:短超时
await manager.run(tx => { ... }, { timeout: 5000 });
// 复杂交易:长超时
await manager.run(tx => { ... }, { timeout: 30000 });
// 跨服务器:更长超时
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
```
### 3. 错误处理
```typescript
const result = await manager.run((tx) => { ... });
if (!result.success) {
// 记录日志
logger.error('Transaction failed', {
transactionId: result.transactionId,
error: result.error,
duration: result.duration,
});
// 通知用户
player.send('error', { message: getErrorMessage(result.error) });
}
```

View File

@@ -0,0 +1,313 @@
---
title: "操作类"
description: "内置的事务操作:货币、背包、交易"
---
## BaseOperation
所有操作类的基类,提供通用的实现模板。
```typescript
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
class MyOperation extends BaseOperation<MyData, MyResult> {
readonly name = 'myOperation';
async validate(ctx: ITransactionContext): Promise<boolean> {
// 验证前置条件
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
// 执行操作
return this.success({ result: 'ok' });
// 或
return this.failure('Something went wrong', 'ERROR_CODE');
}
async compensate(ctx: ITransactionContext): Promise<void> {
// 回滚操作
}
}
```
## CurrencyOperation
处理货币的增加和扣除。
### 扣除货币
```typescript
import { CurrencyOperation } from '@esengine/transaction';
tx.addOperation(new CurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
reason: 'purchase_item',
}));
```
### 增加货币
```typescript
tx.addOperation(new CurrencyOperation({
type: 'add',
playerId: 'player1',
currency: 'diamond',
amount: 50,
reason: 'daily_reward',
}));
```
### 操作数据
```typescript
interface CurrencyOperationData {
type: 'add' | 'deduct'; // 操作类型
playerId: string; // 玩家 ID
currency: string; // 货币类型
amount: number; // 数量
reason?: string; // 原因/来源
}
```
### 操作结果
```typescript
interface CurrencyOperationResult {
beforeBalance: number; // 操作前余额
afterBalance: number; // 操作后余额
}
```
### 自定义数据提供者
```typescript
interface ICurrencyProvider {
getBalance(playerId: string, currency: string): Promise<number>;
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
}
class MyCurrencyProvider implements ICurrencyProvider {
async getBalance(playerId: string, currency: string): Promise<number> {
// 从数据库获取余额
return await db.getCurrency(playerId, currency);
}
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
// 保存到数据库
await db.setCurrency(playerId, currency, amount);
}
}
// 使用自定义提供者
const op = new CurrencyOperation({ ... });
op.setProvider(new MyCurrencyProvider());
tx.addOperation(op);
```
## InventoryOperation
处理物品的添加、移除和更新。
### 添加物品
```typescript
import { InventoryOperation } from '@esengine/transaction';
tx.addOperation(new InventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1,
properties: { enchant: 'fire' },
}));
```
### 移除物品
```typescript
tx.addOperation(new InventoryOperation({
type: 'remove',
playerId: 'player1',
itemId: 'potion_hp',
quantity: 5,
}));
```
### 更新物品
```typescript
tx.addOperation(new InventoryOperation({
type: 'update',
playerId: 'player1',
itemId: 'sword_001',
quantity: 1, // 可选,不传则保持原数量
properties: { enchant: 'lightning', level: 5 },
}));
```
### 操作数据
```typescript
interface InventoryOperationData {
type: 'add' | 'remove' | 'update'; // 操作类型
playerId: string; // 玩家 ID
itemId: string; // 物品 ID
quantity: number; // 数量
properties?: Record<string, unknown>; // 物品属性
reason?: string; // 原因/来源
}
```
### 操作结果
```typescript
interface InventoryOperationResult {
beforeItem?: ItemData; // 操作前物品
afterItem?: ItemData; // 操作后物品
}
interface ItemData {
itemId: string;
quantity: number;
properties?: Record<string, unknown>;
}
```
### 自定义数据提供者
```typescript
interface IInventoryProvider {
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
hasCapacity?(playerId: string, count: number): Promise<boolean>;
}
class MyInventoryProvider implements IInventoryProvider {
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
return await db.getItem(playerId, itemId);
}
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
if (item) {
await db.saveItem(playerId, itemId, item);
} else {
await db.deleteItem(playerId, itemId);
}
}
async hasCapacity(playerId: string, count: number): Promise<boolean> {
const current = await db.getItemCount(playerId);
const max = await db.getMaxCapacity(playerId);
return current + count <= max;
}
}
```
## TradeOperation
处理玩家之间的物品和货币交换。
### 基本用法
```typescript
import { TradeOperation } from '@esengine/transaction';
tx.addOperation(new TradeOperation({
tradeId: 'trade_001',
partyA: {
playerId: 'player1',
items: [{ itemId: 'sword', quantity: 1 }],
currencies: [{ currency: 'diamond', amount: 10 }],
},
partyB: {
playerId: 'player2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
reason: 'player_trade',
}));
```
### 操作数据
```typescript
interface TradeOperationData {
tradeId: string; // 交易 ID
partyA: TradeParty; // 交易发起方
partyB: TradeParty; // 交易接收方
reason?: string; // 原因/备注
}
interface TradeParty {
playerId: string; // 玩家 ID
items?: TradeItem[]; // 给出的物品
currencies?: TradeCurrency[]; // 给出的货币
}
interface TradeItem {
itemId: string;
quantity: number;
}
interface TradeCurrency {
currency: string;
amount: number;
}
```
### 执行流程
TradeOperation 内部会生成以下子操作序列:
```
1. 移除 partyA 的物品
2. 添加 partyB 的物品(来自 partyA
3. 扣除 partyA 的货币
4. 增加 partyB 的货币(来自 partyA
5. 移除 partyB 的物品
6. 添加 partyA 的物品(来自 partyB
7. 扣除 partyB 的货币
8. 增加 partyA 的货币(来自 partyB
```
任何一步失败都会回滚之前的所有操作。
### 使用自定义提供者
```typescript
const op = new TradeOperation({ ... });
op.setProvider({
currencyProvider: new MyCurrencyProvider(),
inventoryProvider: new MyInventoryProvider(),
});
tx.addOperation(op);
```
## 创建工厂函数
每个操作类都提供工厂函数:
```typescript
import {
createCurrencyOperation,
createInventoryOperation,
createTradeOperation,
} from '@esengine/transaction';
tx.addOperation(createCurrencyOperation({
type: 'deduct',
playerId: 'player1',
currency: 'gold',
amount: 100,
}));
tx.addOperation(createInventoryOperation({
type: 'add',
playerId: 'player1',
itemId: 'sword',
quantity: 1,
}));
```

View File

@@ -0,0 +1,234 @@
---
title: "存储层"
description: "事务存储接口和实现MemoryStorage、RedisStorage、MongoStorage"
---
## 存储接口
所有存储实现都需要实现 `ITransactionStorage` 接口:
```typescript
interface ITransactionStorage {
// 生命周期
close?(): Promise<void>;
// 分布式锁
acquireLock(key: string, ttl: number): Promise<string | null>;
releaseLock(key: string, token: string): Promise<boolean>;
// 事务日志
saveTransaction(tx: TransactionLog): Promise<void>;
getTransaction(id: string): Promise<TransactionLog | null>;
updateTransactionState(id: string, state: TransactionState): Promise<void>;
updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise<void>;
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>;
deleteTransaction(id: string): Promise<void>;
// 数据操作
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<boolean>;
}
```
## MemoryStorage
内存存储,适用于开发和测试环境。
```typescript
import { MemoryStorage } from '@esengine/transaction';
const storage = new MemoryStorage({
maxTransactions: 1000, // 最大事务日志数量
});
const manager = new TransactionManager({ storage });
```
### 特点
- ✅ 无需外部依赖
- ✅ 快速,适合开发调试
- ❌ 数据仅保存在内存中
- ❌ 不支持真正的分布式锁
- ❌ 服务重启后数据丢失
### 测试辅助
```typescript
// 清空所有数据
storage.clear();
// 获取事务数量
console.log(storage.transactionCount);
```
## RedisStorage
Redis 存储,适用于生产环境的分布式系统。使用工厂模式实现惰性连接。
```typescript
import Redis from 'ioredis';
import { RedisStorage } from '@esengine/transaction';
// 工厂模式:惰性连接,首次操作时才创建连接
const storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'tx:', // 键前缀
transactionTTL: 86400, // 事务日志过期时间(秒)
});
const manager = new TransactionManager({ storage });
// 使用后关闭连接
await storage.close();
// 或使用 await using 自动关闭 (TypeScript 5.2+)
await using storage = new RedisStorage({
factory: () => new Redis('redis://localhost:6379')
});
// 作用域结束时自动关闭
```
### 特点
- ✅ 高性能分布式锁
- ✅ 快速读写
- ✅ 支持 TTL 自动过期
- ✅ 适合高并发场景
- ❌ 需要 Redis 服务器
### 分布式锁实现
使用 Redis `SET NX EX` 实现分布式锁:
```typescript
// 获取锁(原子操作)
SET tx:lock:player:123 <token> NX EX 10
// 释放锁Lua 脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
```
### 键结构
```
tx:lock:{key} - 分布式锁
tx:tx:{id} - 事务日志
tx:server:{id}:txs - 服务器事务索引
tx:data:{key} - 业务数据
```
## MongoStorage
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用工厂模式实现惰性连接。
```typescript
import { MongoClient } from 'mongodb';
import { MongoStorage } from '@esengine/transaction';
// 工厂模式:惰性连接,首次操作时才创建连接
const storage = new MongoStorage({
factory: async () => {
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
return client;
},
database: 'game',
transactionCollection: 'transactions', // 事务日志集合
dataCollection: 'transaction_data', // 业务数据集合
lockCollection: 'transaction_locks', // 锁集合
});
// 创建索引(首次运行时执行)
await storage.ensureIndexes();
const manager = new TransactionManager({ storage });
// 使用后关闭连接
await storage.close();
// 或使用 await using 自动关闭 (TypeScript 5.2+)
await using storage = new MongoStorage({ ... });
```
### 特点
- ✅ 持久化存储
- ✅ 支持复杂查询
- ✅ 事务日志可追溯
- ✅ 适合需要审计的场景
- ❌ 相比 Redis 性能略低
- ❌ 需要 MongoDB 服务器
### 索引结构
```javascript
// transactions 集合
{ state: 1 }
{ 'metadata.serverId': 1 }
{ createdAt: 1 }
// transaction_locks 集合
{ expireAt: 1 } // TTL 索引
// transaction_data 集合
{ expireAt: 1 } // TTL 索引
```
### 分布式锁实现
使用 MongoDB 唯一索引实现分布式锁:
```typescript
// 获取锁
db.transaction_locks.insertOne({
_id: 'player:123',
token: '<token>',
expireAt: new Date(Date.now() + 10000)
});
// 如果键已存在,检查是否过期
db.transaction_locks.updateOne(
{ _id: 'player:123', expireAt: { $lt: new Date() } },
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
);
```
## 存储选择指南
| 场景 | 推荐存储 | 理由 |
|------|----------|------|
| 开发/测试 | MemoryStorage | 无依赖,快速启动 |
| 单机生产 | RedisStorage | 高性能,简单 |
| 分布式系统 | RedisStorage | 真正的分布式锁 |
| 需要审计 | MongoStorage | 持久化日志 |
| 混合需求 | Redis + Mongo | Redis 做锁Mongo 做日志 |
## 自定义存储
实现 `ITransactionStorage` 接口创建自定义存储:
```typescript
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
class MyCustomStorage implements ITransactionStorage {
async acquireLock(key: string, ttl: number): Promise<string | null> {
// 实现分布式锁获取逻辑
}
async releaseLock(key: string, token: string): Promise<boolean> {
// 实现分布式锁释放逻辑
}
async saveTransaction(tx: TransactionLog): Promise<void> {
// 保存事务日志
}
// ... 实现其他方法
}
```

View File

@@ -0,0 +1,86 @@
# ESEngine Editor
A cross-platform desktop visual editor built with Tauri 2.x + React 18.
## Prerequisites
Before running the editor, ensure you have the following installed:
- **Node.js** >= 18.x
- **pnpm** >= 10.x
- **Rust** >= 1.70 (for Tauri)
- **Platform-specific dependencies**:
- **Windows**: Microsoft Visual Studio C++ Build Tools
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Linux**: See [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites)
## Quick Start
### 1. Clone and Install
```bash
git clone https://github.com/esengine/esengine.git
cd esengine
pnpm install
```
### 2. Build Dependencies
From the project root:
```bash
pnpm build:editor
```
### 3. Run Editor
```bash
cd packages/editor/editor-app
pnpm tauri:dev
```
## Available Scripts
| Script | Description |
|--------|-------------|
| `pnpm tauri:dev` | Run editor in development mode with hot-reload |
| `pnpm tauri:build` | Build production application |
| `pnpm build:sdk` | Build editor-runtime SDK |
## Project Structure
```
editor-app/
├── src/ # React application source
│ ├── components/ # UI components
│ ├── panels/ # Editor panels
│ └── services/ # Core services
├── src-tauri/ # Tauri (Rust) backend
├── public/ # Static assets
└── scripts/ # Build scripts
```
## Troubleshooting
### Build Errors
```bash
pnpm clean
pnpm install
pnpm build:editor
```
### Rust/Tauri Errors
```bash
rustup update
```
## Documentation
- [ESEngine Documentation](https://esengine.cn/)
- [Tauri Documentation](https://tauri.app/)
## License
MIT License

View File

@@ -0,0 +1,86 @@
# ESEngine 编辑器
基于 Tauri 2.x + React 18 构建的跨平台桌面可视化编辑器。
## 环境要求
运行编辑器前,请确保已安装以下环境:
- **Node.js** >= 18.x
- **pnpm** >= 10.x
- **Rust** >= 1.70 (Tauri 需要)
- **平台相关依赖**
- **Windows**: Microsoft Visual Studio C++ Build Tools
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Linux**: 参考 [Tauri 环境配置](https://tauri.app/v1/guides/getting-started/prerequisites)
## 快速开始
### 1. 克隆并安装
```bash
git clone https://github.com/esengine/esengine.git
cd esengine
pnpm install
```
### 2. 构建依赖
在项目根目录执行:
```bash
pnpm build:editor
```
### 3. 启动编辑器
```bash
cd packages/editor/editor-app
pnpm tauri:dev
```
## 可用脚本
| 脚本 | 说明 |
|------|------|
| `pnpm tauri:dev` | 开发模式运行编辑器(支持热重载)|
| `pnpm tauri:build` | 构建生产版本应用 |
| `pnpm build:sdk` | 构建 editor-runtime SDK |
## 项目结构
```
editor-app/
├── src/ # React 应用源码
│ ├── components/ # UI 组件
│ ├── panels/ # 编辑器面板
│ └── services/ # 核心服务
├── src-tauri/ # Tauri (Rust) 后端
├── public/ # 静态资源
└── scripts/ # 构建脚本
```
## 常见问题
### 构建错误
```bash
pnpm clean
pnpm install
pnpm build:editor
```
### Rust/Tauri 错误
```bash
rustup update
```
## 文档
- [ESEngine 文档](https://esengine.cn/)
- [Tauri 文档](https://tauri.app/)
## 许可证
MIT License

View File

@@ -9,7 +9,7 @@
"build": "npm run build:sdk && tsc && vite build",
"build:watch": "vite build --watch",
"tauri": "tauri",
"copy-modules": "node ../../scripts/copy-engine-modules.mjs",
"copy-modules": "node ../../../scripts/copy-engine-modules.mjs",
"tauri:dev": "npm run build:sdk && npm run copy-modules && tauri dev",
"bundle:runtime": "node scripts/bundle-runtime.mjs",
"tauri:build": "npm run build:sdk && npm run copy-modules && npm run bundle:runtime && tauri build",

File diff suppressed because it is too large Load Diff

View File

@@ -10,16 +10,16 @@ name = "ecs_editor_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.0", features = ["protocol-asset"] }
tauri-plugin-shell = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.0"
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
tauri-plugin-http = "2.0"
tauri-plugin-cli = "2.0"
tauri-plugin-http = "2"
tauri-plugin-cli = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
glob = "0.3"

View File

@@ -30,6 +30,7 @@
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/node-editor": "workspace:*",

View File

@@ -0,0 +1,45 @@
/**
* @zh ESEngine 行为树运行时模块
* @en ESEngine Behavior Tree Runtime Module
*
* @zh 纯运行时模块,不依赖 asset-system。资产加载由编辑器在 install 时注册。
* @en Pure runtime module, no asset-system dependency. Asset loading is registered by editor during install.
*/
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
import {
BehaviorTreeRuntimeComponent,
BehaviorTreeExecutionSystem,
BehaviorTreeAssetManager,
GlobalBlackboardService,
BehaviorTreeSystemToken
} from '@esengine/behavior-tree';
export class BehaviorTreeRuntimeModule implements IRuntimeModule {
registerComponents(registry: IComponentRegistry): void {
registry.register(BehaviorTreeRuntimeComponent);
}
registerServices(services: ServiceContainer): void {
if (!services.isRegistered(GlobalBlackboardService)) {
services.registerSingleton(GlobalBlackboardService);
}
if (!services.isRegistered(BehaviorTreeAssetManager)) {
services.registerSingleton(BehaviorTreeAssetManager);
}
}
createSystems(scene: IScene, context: SystemContext): void {
const ecsServices = (context as { ecsServices?: ServiceContainer }).ecsServices;
const behaviorTreeSystem = new BehaviorTreeExecutionSystem(ecsServices);
if (context.isEditor) {
behaviorTreeSystem.enabled = false;
}
scene.addSystem(behaviorTreeSystem);
context.services.register(BehaviorTreeSystemToken, behaviorTreeSystem);
}
}

View File

@@ -30,8 +30,11 @@ import {
LocaleService,
} from '@esengine/editor-runtime';
// Runtime imports from @esengine/behavior-tree package
import { BehaviorTreeRuntimeComponent, BehaviorTreeRuntimeModule } from '@esengine/behavior-tree';
// Runtime imports
import { BehaviorTreeRuntimeComponent, BehaviorTreeAssetType } from '@esengine/behavior-tree';
import { AssetManagerToken } from '@esengine/asset-system';
import { BehaviorTreeRuntimeModule } from './BehaviorTreeRuntimeModule';
import { BehaviorTreeLoader } from './runtime/BehaviorTreeLoader';
// Editor components and services
import { BehaviorTreeService } from './services/BehaviorTreeService';
@@ -71,6 +74,10 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
// 设置插件上下文
PluginContext.setServices(services);
// 注册行为树资产加载器到 AssetManager
// Register behavior tree asset loader to AssetManager
this.registerAssetLoader();
// 注册服务
this.registerServices(services);
@@ -92,6 +99,22 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader {
logger.info('BehaviorTree editor module installed');
}
/**
* 注册行为树资产加载器
* Register behavior tree asset loader
*/
private registerAssetLoader(): void {
try {
const assetManager = PluginAPI.resolve(AssetManagerToken);
if (assetManager) {
assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader());
logger.info('BehaviorTree asset loader registered');
}
} catch (error) {
logger.warn('Failed to register asset loader:', error);
}
}
private registerAssetCreationMappings(services: ServiceContainer): void {
try {
const fileActionRegistry = services.resolve<FileActionRegistry>(IFileActionRegistry);
@@ -376,7 +399,7 @@ export const BehaviorTreePlugin: IEditorPlugin = {
editorModule: new BehaviorTreeEditorModule(),
};
export { BehaviorTreeRuntimeModule };
// BehaviorTreeRuntimeModule is internal, not re-exported
// Re-exports for editor functionality
export { PluginContext } from './PluginContext';

View File

@@ -0,0 +1,61 @@
/**
* @zh ESEngine 资产加载器
* @en ESEngine asset loader
* @internal
*/
import { Core } from '@esengine/ecs-framework';
import {
BehaviorTreeAssetManager,
EditorToBehaviorTreeDataConverter,
BehaviorTreeAssetType,
type BehaviorTreeData
} from '@esengine/behavior-tree';
/**
* @zh 行为树资产接口
* @en Behavior tree asset interface
* @internal
*/
export interface IBehaviorTreeAsset {
data: BehaviorTreeData;
path: string;
}
/**
* @zh 行为树加载器
* @en Behavior tree loader implementing IAssetLoader interface
* @internal
*/
export class BehaviorTreeLoader {
readonly supportedType = BehaviorTreeAssetType;
readonly supportedExtensions = ['.btree'];
readonly contentType = 'text' as const;
async parse(content: { text?: string }, context: { metadata: { path: string } }): Promise<IBehaviorTreeAsset> {
if (!content.text) {
throw new Error('Behavior tree content is empty');
}
const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content.text);
const assetPath = context.metadata.path;
treeData.id = assetPath;
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager) {
btAssetManager.loadAsset(treeData);
}
return {
data: treeData,
path: assetPath
};
}
dispose(asset: IBehaviorTreeAsset): void {
const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager);
if (btAssetManager && asset.data) {
btAssetManager.unloadAsset(asset.data.id);
}
}
}

View File

@@ -1,23 +1,15 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -2,6 +2,8 @@ import { defineConfig } from 'tsup';
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...editorOnlyPreset(),
...editorOnlyPreset({
external: ['@esengine/asset-system']
}),
tsconfig: 'tsconfig.build.json'
});

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -1,6 +1,7 @@
{
"extends": "../build-config/tsconfig.json",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",

View File

@@ -5,6 +5,7 @@ export default defineConfig({
format: ['esm'],
dts: true,
clean: true,
tsconfig: 'tsconfig.build.json',
external: [
'react',
'react-dom',

View File

@@ -1,5 +1,26 @@
# @esengine/behavior-tree
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/behavior-tree",
"version": "1.0.3",
"version": "3.0.0",
"description": "ECS-based AI behavior tree system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,5 +1,26 @@
# @esengine/blueprint
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/blueprint",
"version": "1.0.2",
"version": "3.0.0",
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -1,5 +1,161 @@
# @esengine/ecs-framework
## 2.6.0
### Minor Changes
- feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁
### 新功能
**@NetworkEntity 装饰器**
- 标记组件为网络实体,自动广播 spawn/despawn 消息
- 支持 `autoSpawn``autoDespawn` 配置选项
- 通过事件系统(`ECSEventType.COMPONENT_ADDED` / `ECSEventType.ENTITY_DESTROYED`)实现
**ECSRoom 增强**
- 新增 `enableAutoNetworkEntity` 配置选项(默认启用)
- 自动监听组件添加和实体销毁事件
- 简化 GameRoom 实现,无需手动回调
### 改进
**Entity 事件**
- `Entity.destroy()` 现在发出 `entity:destroyed` 事件
- `Entity.active` 变化时发出 `entity:enabled` / `entity:disabled` 事件
- 使用 `ECSEventType` 常量替代硬编码字符串
### 使用示例
```typescript
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
@ECSComponent('Enemy')
@NetworkEntity('Enemy')
class EnemyComponent extends Component {
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
// 服务端
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
entity.destroy(); // 自动广播 despawn
```
## 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

View File

@@ -1,16 +1,18 @@
{
"name": "@esengine/ecs-framework",
"version": "2.4.4",
"version": "2.6.0",
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"unpkg": "dist/index.umd.js",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
"require": "./dist/index.cjs",
"source": "./src/index.ts"
}
},
"files": [
@@ -50,23 +52,24 @@
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@eslint/js": "^9.37.0",
"@jest/globals": "^29.7.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.19.17",
"@eslint/js": "^9.37.0",
"eslint": "^9.37.0",
"typescript-eslint": "^8.46.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^5.0.0",
"rollup": "^4.42.0",
"rollup-plugin-dts": "^6.2.1",
"rollup-plugin-sourcemaps": "^0.6.3",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1"
},
"publishConfig": {
"access": "public",

View File

@@ -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;

View File

@@ -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);

View File

@@ -7,14 +7,7 @@ import { getComponentInstanceTypeName, getComponentTypeName } from './Decorators
import { generateGUID } from '../Utils/GUID';
import type { IScene } from './IScene';
import { EntityHandle, NULL_HANDLE } from './Core/EntityHandle';
/**
* @zh 组件活跃状态变化接口
* @en Interface for component active state change
*/
interface IActiveChangeable {
onActiveChanged(): void;
}
import { ECSEventType } from './CoreEvents';
/**
* @zh 比较两个实体的优先级
@@ -482,7 +475,7 @@ export class Entity {
}
if (this.scene.eventSystem) {
this.scene.eventSystem.emitSync('component:added', {
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_ADDED, {
timestamp: Date.now(),
source: 'Entity',
entityId: this.id,
@@ -639,7 +632,7 @@ export class Entity {
component.entityId = null;
if (this.scene?.eventSystem) {
this.scene.eventSystem.emitSync('component:removed', {
this.scene.eventSystem.emitSync(ECSEventType.COMPONENT_REMOVED, {
timestamp: Date.now(),
source: 'Entity',
entityId: this.id,
@@ -770,19 +763,23 @@ export class Entity {
}
/**
* 活跃状态改变时的回调
* @zh 活跃状态改变时的回调
* @en Callback when active state changes
*
* @zh 通过事件系统发出 ENTITY_ENABLED 或 ENTITY_DISABLED 事件,
* 组件可以通过监听这些事件来响应实体状态变化。
* @en Emits ENTITY_ENABLED or ENTITY_DISABLED event through the event system.
* Components can listen to these events to respond to entity state changes.
*/
private onActiveChanged(): void {
for (const component of this.components) {
if ('onActiveChanged' in component && typeof component.onActiveChanged === 'function') {
(component as IActiveChangeable).onActiveChanged();
}
}
if (this.scene?.eventSystem) {
const eventType = this._active
? ECSEventType.ENTITY_ENABLED
: ECSEventType.ENTITY_DISABLED;
if (this.scene && this.scene.eventSystem) {
this.scene.eventSystem.emitSync('entity:activeChanged', {
this.scene.eventSystem.emitSync(eventType, {
entity: this,
active: this._active
scene: this.scene,
});
}
}
@@ -801,6 +798,15 @@ export class Entity {
this._isDestroyed = true;
// 在清理之前发出销毁事件(组件仍然可访问)
if (this.scene?.eventSystem) {
this.scene.eventSystem.emitSync(ECSEventType.ENTITY_DESTROYED, {
entity: this,
entityId: this.id,
scene: this.scene,
});
}
if (this.scene && this.scene.referenceTracker) {
this.scene.referenceTracker.clearReferencesTo(this.id);
this.scene.referenceTracker.unregisterEntityScene(this.id);

View File

@@ -508,7 +508,9 @@ export class Scene implements IScene {
}
/**
* 更新场景
* @zh 更新场景
* @en Update scene
* @internal 由 SceneManager 或 World 调用,用户不应直接调用
*/
public update() {
this.epochManager.increment();

View File

@@ -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 {
// 处理延迟场景切换

View 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;
}
}

View File

@@ -0,0 +1,147 @@
/**
* @zh 网络实体装饰器
* @en Network entity decorator
*
* @zh 提供 @NetworkEntity 装饰器,用于标记需要自动广播生成/销毁的组件
* @en Provides @NetworkEntity decorator to mark components for automatic spawn/despawn broadcasting
*/
/**
* @zh 网络实体元数据的 Symbol 键
* @en Symbol key for network entity metadata
*/
export const NETWORK_ENTITY_METADATA = Symbol('NetworkEntityMetadata');
/**
* @zh 网络实体元数据
* @en Network entity metadata
*/
export interface NetworkEntityMetadata {
/**
* @zh 预制体类型名称(用于客户端重建实体)
* @en Prefab type name (used by client to reconstruct entity)
*/
prefabType: string;
/**
* @zh 是否自动广播生成
* @en Whether to auto-broadcast spawn
* @default true
*/
autoSpawn: boolean;
/**
* @zh 是否自动广播销毁
* @en Whether to auto-broadcast despawn
* @default true
*/
autoDespawn: boolean;
}
/**
* @zh 网络实体装饰器配置选项
* @en Network entity decorator options
*/
export interface NetworkEntityOptions {
/**
* @zh 是否自动广播生成
* @en Whether to auto-broadcast spawn
* @default true
*/
autoSpawn?: boolean;
/**
* @zh 是否自动广播销毁
* @en Whether to auto-broadcast despawn
* @default true
*/
autoDespawn?: boolean;
}
/**
* @zh 网络实体装饰器
* @en Network entity decorator
*
* @zh 标记组件类为网络实体。当包含此组件的实体被创建或销毁时,
* ECSRoom 会自动广播相应的 spawn/despawn 消息给所有客户端。
* @en Marks a component class as a network entity. When an entity containing
* this component is created or destroyed, ECSRoom will automatically broadcast
* the corresponding spawn/despawn messages to all clients.
*
* @param prefabType - @zh 预制体类型名称 @en Prefab type name
* @param options - @zh 可选配置 @en Optional configuration
*
* @example
* ```typescript
* import { Component, ECSComponent, NetworkEntity, sync } from '@esengine/ecs-framework';
*
* @ECSComponent('Enemy')
* @NetworkEntity('Enemy')
* class EnemyComponent extends Component {
* @sync('float32') x: number = 0;
* @sync('float32') y: number = 0;
* @sync('uint16') health: number = 100;
* }
*
* // 当添加此组件到实体时ECSRoom 会自动广播 spawn
* const enemy = scene.createEntity('Enemy');
* enemy.addComponent(new EnemyComponent()); // 自动广播给所有客户端
*
* // 当实体销毁时,自动广播 despawn
* enemy.destroy(); // 自动广播给所有客户端
* ```
*
* @example
* ```typescript
* // 只自动广播生成,销毁由手动控制
* @ECSComponent('Bullet')
* @NetworkEntity('Bullet', { autoDespawn: false })
* class BulletComponent extends Component {
* @sync('float32') x: number = 0;
* @sync('float32') y: number = 0;
* }
* ```
*/
export function NetworkEntity(prefabType: string, options?: NetworkEntityOptions) {
return function <T extends new (...args: any[]) => any>(target: T): T {
const metadata: NetworkEntityMetadata = {
prefabType,
autoSpawn: options?.autoSpawn ?? true,
autoDespawn: options?.autoDespawn ?? true,
};
(target as any)[NETWORK_ENTITY_METADATA] = metadata;
return target;
};
}
/**
* @zh 获取组件类的网络实体元数据
* @en Get network entity metadata for a component class
*
* @param componentClass - @zh 组件类或组件实例 @en Component class or instance
* @returns @zh 网络实体元数据,如果不存在则返回 null @en Network entity metadata, or null if not exists
*/
export function getNetworkEntityMetadata(componentClass: any): NetworkEntityMetadata | null {
if (!componentClass) {
return null;
}
const constructor = typeof componentClass === 'function'
? componentClass
: componentClass.constructor;
return constructor[NETWORK_ENTITY_METADATA] || null;
}
/**
* @zh 检查组件是否标记为网络实体
* @en Check if a component is marked as a network entity
*
* @param component - @zh 组件类或组件实例 @en Component class or instance
* @returns @zh 如果是网络实体返回 true @en Returns true if is a network entity
*/
export function isNetworkEntity(component: any): boolean {
return getNetworkEntityMetadata(component) !== null;
}

View 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;
}

View 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;
}
}

View 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);
}
}

View 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;
}

View 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();
}

View 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';

View 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];
}

View File

@@ -0,0 +1,65 @@
/**
* @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';
// Network Entity Decorator
export {
NetworkEntity,
getNetworkEntityMetadata,
isNetworkEntity,
NETWORK_ENTITY_METADATA,
type NetworkEntityMetadata,
type NetworkEntityOptions
} from './NetworkEntityDecorator';
// Encoding
export * from './encoding';

View 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');

View File

@@ -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;

View File

@@ -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';

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View File

@@ -1,5 +1,29 @@
# @esengine/fsm
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
- @esengine/blueprint@3.0.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/fsm",
"version": "1.0.3",
"version": "3.0.0",
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,112 @@
# @esengine/network
## 4.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
- @esengine/blueprint@3.0.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "2.2.0",
"version": "4.0.0",
"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:*",

View File

@@ -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'
// ============================================================================

View 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);
}

View File

@@ -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';

View File

@@ -1,5 +1,29 @@
# @esengine/pathfinding
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
- @esengine/blueprint@3.0.0
## 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

View File

@@ -0,0 +1,247 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { AStarPathfinder } from '../../src/core/AStarPathfinder';
import { GridMap } from '../../src/grid/GridMap';
describe('AStarPathfinder', () => {
let grid: GridMap;
let pathfinder: AStarPathfinder;
beforeEach(() => {
grid = new GridMap(10, 10);
pathfinder = new AStarPathfinder(grid);
});
// =========================================================================
// Basic Pathfinding
// =========================================================================
describe('basic pathfinding', () => {
it('should find path between adjacent nodes', () => {
const result = pathfinder.findPath(0, 0, 1, 0);
expect(result.found).toBe(true);
expect(result.path.length).toBe(2);
expect(result.path[0]).toEqual({ x: 0, y: 0 });
expect(result.path[1]).toEqual({ x: 1, y: 0 });
});
it('should return start position for same start and end', () => {
const result = pathfinder.findPath(5, 5, 5, 5);
expect(result.found).toBe(true);
expect(result.path.length).toBe(1);
expect(result.path[0]).toEqual({ x: 5, y: 5 });
expect(result.cost).toBe(0);
});
it('should find diagonal path', () => {
const result = pathfinder.findPath(0, 0, 5, 5);
expect(result.found).toBe(true);
expect(result.path.length).toBeGreaterThan(1);
expect(result.path[0]).toEqual({ x: 0, y: 0 });
expect(result.path[result.path.length - 1]).toEqual({ x: 5, y: 5 });
});
it('should find path across grid', () => {
const result = pathfinder.findPath(0, 0, 9, 9);
expect(result.found).toBe(true);
expect(result.path[0]).toEqual({ x: 0, y: 0 });
expect(result.path[result.path.length - 1]).toEqual({ x: 9, y: 9 });
});
});
// =========================================================================
// Obstacles
// =========================================================================
describe('obstacles', () => {
it('should find path around single obstacle', () => {
grid.setWalkable(5, 5, false);
const result = pathfinder.findPath(4, 5, 6, 5);
expect(result.found).toBe(true);
expect(result.path.length).toBeGreaterThan(2);
});
it('should find path around wall', () => {
// Create vertical wall
for (let y = 2; y <= 7; y++) {
grid.setWalkable(5, y, false);
}
const result = pathfinder.findPath(3, 5, 7, 5);
expect(result.found).toBe(true);
// Path should go around the wall
expect(result.path.every(p => p.x !== 5 || p.y < 2 || p.y > 7)).toBe(true);
});
it('should return empty path when blocked', () => {
// Block completely around start
grid.setWalkable(1, 0, false);
grid.setWalkable(0, 1, false);
grid.setWalkable(1, 1, false);
const result = pathfinder.findPath(0, 0, 9, 9);
expect(result.found).toBe(false);
expect(result.path.length).toBe(0);
});
it('should return empty path when start is blocked', () => {
grid.setWalkable(0, 0, false);
const result = pathfinder.findPath(0, 0, 5, 5);
expect(result.found).toBe(false);
});
it('should return empty path when end is blocked', () => {
grid.setWalkable(5, 5, false);
const result = pathfinder.findPath(0, 0, 5, 5);
expect(result.found).toBe(false);
});
});
// =========================================================================
// Out of Bounds
// =========================================================================
describe('out of bounds', () => {
it('should return empty path for out of bounds start', () => {
const result = pathfinder.findPath(-1, 0, 5, 5);
expect(result.found).toBe(false);
});
it('should return empty path for out of bounds end', () => {
const result = pathfinder.findPath(0, 0, 100, 100);
expect(result.found).toBe(false);
});
});
// =========================================================================
// Cost Calculation
// =========================================================================
describe('cost calculation', () => {
it('should calculate correct cost for straight path', () => {
const grid4 = new GridMap(10, 10, { allowDiagonal: false });
const pathfinder4 = new AStarPathfinder(grid4);
const result = pathfinder4.findPath(0, 0, 5, 0);
expect(result.found).toBe(true);
expect(result.cost).toBe(5);
});
it('should prefer lower cost paths', () => {
// Create high cost area
for (let y = 0; y < 10; y++) {
grid.setCost(5, y, 10);
}
const result = pathfinder.findPath(4, 5, 6, 5);
// Should go around the high cost column if possible
expect(result.found).toBe(true);
});
});
// =========================================================================
// Options
// =========================================================================
describe('options', () => {
it('should respect maxNodes limit', () => {
// Large grid with path
const largeGrid = new GridMap(100, 100);
const largePF = new AStarPathfinder(largeGrid);
const result = largePF.findPath(0, 0, 99, 99, { maxNodes: 10 });
// Should fail due to node limit
expect(result.nodesSearched).toBeLessThanOrEqual(10);
});
it('should use heuristic weight', () => {
const result1 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 1.0 });
const result2 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 2.0 });
expect(result1.found).toBe(true);
expect(result2.found).toBe(true);
// Higher weight may search fewer nodes but may not be optimal
expect(result2.nodesSearched).toBeLessThanOrEqual(result1.nodesSearched);
});
});
// =========================================================================
// Clear
// =========================================================================
describe('clear', () => {
it('should allow reuse after clear', () => {
const result1 = pathfinder.findPath(0, 0, 5, 5);
expect(result1.found).toBe(true);
pathfinder.clear();
const result2 = pathfinder.findPath(0, 0, 9, 9);
expect(result2.found).toBe(true);
});
});
// =========================================================================
// Maze
// =========================================================================
describe('maze solving', () => {
it('should solve simple maze', () => {
const mazeStr = `
..........
.########.
..........
.########.
..........
.########.
..........
.########.
..........
..........`.trim();
grid.loadFromString(mazeStr);
const result = pathfinder.findPath(0, 0, 9, 9);
expect(result.found).toBe(true);
// Path should not pass through walls
for (const point of result.path) {
expect(grid.isWalkable(point.x, point.y)).toBe(true);
}
});
});
// =========================================================================
// Path Quality
// =========================================================================
describe('path quality', () => {
it('should find shortest path in open area', () => {
const result = pathfinder.findPath(0, 0, 3, 0);
expect(result.found).toBe(true);
// Straight line should be 4 points
expect(result.path.length).toBe(4);
});
it('should find optimal diagonal path', () => {
const result = pathfinder.findPath(0, 0, 3, 3);
expect(result.found).toBe(true);
// Pure diagonal should be 4 points
expect(result.path.length).toBe(4);
});
});
// =========================================================================
// Nodes Searched
// =========================================================================
describe('nodesSearched', () => {
it('should track nodes searched', () => {
const result = pathfinder.findPath(0, 0, 9, 9);
expect(result.nodesSearched).toBeGreaterThan(0);
});
it('should search only 1 node for same position', () => {
const result = pathfinder.findPath(5, 5, 5, 5);
expect(result.nodesSearched).toBe(1);
});
});
});

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { BinaryHeap } from '../../src/core/BinaryHeap';
describe('BinaryHeap', () => {
let heap: BinaryHeap<number>;
beforeEach(() => {
heap = new BinaryHeap<number>((a, b) => a - b);
});
// =========================================================================
// Basic Operations
// =========================================================================
describe('basic operations', () => {
it('should start empty', () => {
expect(heap.isEmpty).toBe(true);
expect(heap.size).toBe(0);
});
it('should push and pop single element', () => {
heap.push(5);
expect(heap.isEmpty).toBe(false);
expect(heap.size).toBe(1);
expect(heap.pop()).toBe(5);
expect(heap.isEmpty).toBe(true);
});
it('should return undefined when popping empty heap', () => {
expect(heap.pop()).toBeUndefined();
});
it('should peek without removing', () => {
heap.push(5);
expect(heap.peek()).toBe(5);
expect(heap.size).toBe(1);
});
it('should return undefined when peeking empty heap', () => {
expect(heap.peek()).toBeUndefined();
});
});
// =========================================================================
// Min-Heap Property
// =========================================================================
describe('min-heap property', () => {
it('should always pop minimum element', () => {
heap.push(5);
heap.push(3);
heap.push(7);
heap.push(1);
heap.push(9);
expect(heap.pop()).toBe(1);
expect(heap.pop()).toBe(3);
expect(heap.pop()).toBe(5);
expect(heap.pop()).toBe(7);
expect(heap.pop()).toBe(9);
});
it('should handle duplicate values', () => {
heap.push(3);
heap.push(3);
heap.push(3);
expect(heap.pop()).toBe(3);
expect(heap.pop()).toBe(3);
expect(heap.pop()).toBe(3);
expect(heap.isEmpty).toBe(true);
});
it('should handle already sorted input', () => {
heap.push(1);
heap.push(2);
heap.push(3);
heap.push(4);
heap.push(5);
expect(heap.pop()).toBe(1);
expect(heap.pop()).toBe(2);
expect(heap.pop()).toBe(3);
expect(heap.pop()).toBe(4);
expect(heap.pop()).toBe(5);
});
it('should handle reverse sorted input', () => {
heap.push(5);
heap.push(4);
heap.push(3);
heap.push(2);
heap.push(1);
expect(heap.pop()).toBe(1);
expect(heap.pop()).toBe(2);
expect(heap.pop()).toBe(3);
expect(heap.pop()).toBe(4);
expect(heap.pop()).toBe(5);
});
});
// =========================================================================
// Update Operation
// =========================================================================
describe('update operation', () => {
it('should update element position after value change', () => {
interface Item { value: number }
const itemHeap = new BinaryHeap<Item>((a, b) => a.value - b.value);
const item1 = { value: 5 };
const item2 = { value: 3 };
const item3 = { value: 7 };
itemHeap.push(item1);
itemHeap.push(item2);
itemHeap.push(item3);
// Change item1 to be smallest
item1.value = 1;
itemHeap.update(item1);
expect(itemHeap.pop()).toBe(item1);
expect(itemHeap.pop()).toBe(item2);
expect(itemHeap.pop()).toBe(item3);
});
it('should handle update of non-existent element gracefully', () => {
heap.push(1);
heap.push(2);
heap.update(999); // Should not throw
expect(heap.size).toBe(2);
});
});
// =========================================================================
// Contains Operation
// =========================================================================
describe('contains operation', () => {
it('should check if element exists', () => {
heap.push(1);
heap.push(2);
heap.push(3);
expect(heap.contains(2)).toBe(true);
expect(heap.contains(5)).toBe(false);
});
it('should return false for empty heap', () => {
expect(heap.contains(1)).toBe(false);
});
});
// =========================================================================
// Clear Operation
// =========================================================================
describe('clear operation', () => {
it('should clear all elements', () => {
heap.push(1);
heap.push(2);
heap.push(3);
heap.clear();
expect(heap.isEmpty).toBe(true);
expect(heap.size).toBe(0);
});
});
// =========================================================================
// Custom Comparator
// =========================================================================
describe('custom comparator', () => {
it('should work as max-heap with reversed comparator', () => {
const maxHeap = new BinaryHeap<number>((a, b) => b - a);
maxHeap.push(5);
maxHeap.push(3);
maxHeap.push(7);
maxHeap.push(1);
maxHeap.push(9);
expect(maxHeap.pop()).toBe(9);
expect(maxHeap.pop()).toBe(7);
expect(maxHeap.pop()).toBe(5);
expect(maxHeap.pop()).toBe(3);
expect(maxHeap.pop()).toBe(1);
});
it('should work with object comparator', () => {
interface Task { priority: number; name: string }
const taskHeap = new BinaryHeap<Task>((a, b) => a.priority - b.priority);
taskHeap.push({ priority: 3, name: 'C' });
taskHeap.push({ priority: 1, name: 'A' });
taskHeap.push({ priority: 2, name: 'B' });
expect(taskHeap.pop()?.name).toBe('A');
expect(taskHeap.pop()?.name).toBe('B');
expect(taskHeap.pop()?.name).toBe('C');
});
});
// =========================================================================
// Large Dataset
// =========================================================================
describe('large dataset', () => {
it('should handle 1000 random elements', () => {
const elements: number[] = [];
for (let i = 0; i < 1000; i++) {
const value = Math.floor(Math.random() * 10000);
elements.push(value);
heap.push(value);
}
elements.sort((a, b) => a - b);
for (const expected of elements) {
expect(heap.pop()).toBe(expected);
}
});
});
});

View File

@@ -0,0 +1,219 @@
import { describe, it, expect } from 'vitest';
import {
manhattanDistance,
euclideanDistance,
chebyshevDistance,
octileDistance,
createPoint
} from '../../src/core/IPathfinding';
describe('Heuristic Functions', () => {
// =========================================================================
// Manhattan Distance
// =========================================================================
describe('manhattanDistance', () => {
it('should return 0 for same point', () => {
const p = createPoint(5, 5);
expect(manhattanDistance(p, p)).toBe(0);
});
it('should calculate horizontal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 0);
expect(manhattanDistance(a, b)).toBe(5);
});
it('should calculate vertical distance', () => {
const a = createPoint(0, 0);
const b = createPoint(0, 5);
expect(manhattanDistance(a, b)).toBe(5);
});
it('should calculate diagonal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 4);
expect(manhattanDistance(a, b)).toBe(7); // |3| + |4| = 7
});
it('should handle negative coordinates', () => {
const a = createPoint(-2, -3);
const b = createPoint(2, 3);
expect(manhattanDistance(a, b)).toBe(10); // |4| + |6| = 10
});
it('should be symmetric', () => {
const a = createPoint(1, 2);
const b = createPoint(4, 6);
expect(manhattanDistance(a, b)).toBe(manhattanDistance(b, a));
});
});
// =========================================================================
// Euclidean Distance
// =========================================================================
describe('euclideanDistance', () => {
it('should return 0 for same point', () => {
const p = createPoint(5, 5);
expect(euclideanDistance(p, p)).toBe(0);
});
it('should calculate horizontal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 0);
expect(euclideanDistance(a, b)).toBe(5);
});
it('should calculate vertical distance', () => {
const a = createPoint(0, 0);
const b = createPoint(0, 5);
expect(euclideanDistance(a, b)).toBe(5);
});
it('should calculate 3-4-5 triangle', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 4);
expect(euclideanDistance(a, b)).toBe(5);
});
it('should calculate diagonal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(1, 1);
expect(euclideanDistance(a, b)).toBeCloseTo(Math.SQRT2, 10);
});
it('should handle negative coordinates', () => {
const a = createPoint(-3, -4);
const b = createPoint(0, 0);
expect(euclideanDistance(a, b)).toBe(5);
});
it('should be symmetric', () => {
const a = createPoint(1, 2);
const b = createPoint(4, 6);
expect(euclideanDistance(a, b)).toBeCloseTo(euclideanDistance(b, a), 10);
});
});
// =========================================================================
// Chebyshev Distance
// =========================================================================
describe('chebyshevDistance', () => {
it('should return 0 for same point', () => {
const p = createPoint(5, 5);
expect(chebyshevDistance(p, p)).toBe(0);
});
it('should calculate horizontal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 0);
expect(chebyshevDistance(a, b)).toBe(5);
});
it('should calculate vertical distance', () => {
const a = createPoint(0, 0);
const b = createPoint(0, 5);
expect(chebyshevDistance(a, b)).toBe(5);
});
it('should calculate diagonal as max of dx, dy', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 4);
expect(chebyshevDistance(a, b)).toBe(4); // max(3, 4) = 4
});
it('should return same value for equal dx and dy', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 5);
expect(chebyshevDistance(a, b)).toBe(5);
});
it('should be symmetric', () => {
const a = createPoint(1, 2);
const b = createPoint(4, 6);
expect(chebyshevDistance(a, b)).toBe(chebyshevDistance(b, a));
});
});
// =========================================================================
// Octile Distance
// =========================================================================
describe('octileDistance', () => {
it('should return 0 for same point', () => {
const p = createPoint(5, 5);
expect(octileDistance(p, p)).toBe(0);
});
it('should calculate horizontal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 0);
expect(octileDistance(a, b)).toBe(5);
});
it('should calculate vertical distance', () => {
const a = createPoint(0, 0);
const b = createPoint(0, 5);
expect(octileDistance(a, b)).toBe(5);
});
it('should calculate pure diagonal distance', () => {
const a = createPoint(0, 0);
const b = createPoint(5, 5);
// 5 diagonal moves = 5 * sqrt(2)
expect(octileDistance(a, b)).toBeCloseTo(5 * Math.SQRT2, 10);
});
it('should calculate mixed diagonal and straight', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 5);
// 3 diagonal + 2 straight = 3*sqrt(2) + 2
const expected = 3 * Math.SQRT2 + 2;
expect(octileDistance(a, b)).toBeCloseTo(expected, 10);
});
it('should be symmetric', () => {
const a = createPoint(1, 2);
const b = createPoint(4, 6);
expect(octileDistance(a, b)).toBeCloseTo(octileDistance(b, a), 10);
});
it('should be between Manhattan and Euclidean for diagonal', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 4);
const manhattan = manhattanDistance(a, b);
const euclidean = euclideanDistance(a, b);
const octile = octileDistance(a, b);
expect(octile).toBeLessThan(manhattan);
expect(octile).toBeGreaterThan(euclidean);
});
});
// =========================================================================
// createPoint
// =========================================================================
describe('createPoint', () => {
it('should create point with correct coordinates', () => {
const p = createPoint(3, 4);
expect(p.x).toBe(3);
expect(p.y).toBe(4);
});
it('should handle negative coordinates', () => {
const p = createPoint(-5, -10);
expect(p.x).toBe(-5);
expect(p.y).toBe(-10);
});
it('should handle decimal coordinates', () => {
const p = createPoint(3.5, 4.7);
expect(p.x).toBe(3.5);
expect(p.y).toBe(4.7);
});
});
});

View File

@@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GridMap, GridNode, DIRECTIONS_4, DIRECTIONS_8 } from '../../src/grid/GridMap';
describe('GridMap', () => {
let grid: GridMap;
beforeEach(() => {
grid = new GridMap(10, 10);
});
// =========================================================================
// Construction
// =========================================================================
describe('construction', () => {
it('should create grid with correct dimensions', () => {
expect(grid.width).toBe(10);
expect(grid.height).toBe(10);
});
it('should have all nodes walkable by default', () => {
for (let y = 0; y < 10; y++) {
for (let x = 0; x < 10; x++) {
expect(grid.isWalkable(x, y)).toBe(true);
}
}
});
it('should create small grid', () => {
const small = new GridMap(1, 1);
expect(small.width).toBe(1);
expect(small.height).toBe(1);
expect(small.getNodeAt(0, 0)).not.toBeNull();
});
});
// =========================================================================
// Node Access
// =========================================================================
describe('getNodeAt', () => {
it('should return node at valid position', () => {
const node = grid.getNodeAt(5, 5);
expect(node).not.toBeNull();
expect(node?.position.x).toBe(5);
expect(node?.position.y).toBe(5);
});
it('should return null for out of bounds', () => {
expect(grid.getNodeAt(-1, 0)).toBeNull();
expect(grid.getNodeAt(0, -1)).toBeNull();
expect(grid.getNodeAt(10, 0)).toBeNull();
expect(grid.getNodeAt(0, 10)).toBeNull();
});
it('should return node with correct id', () => {
const node = grid.getNodeAt(3, 4);
expect(node?.id).toBe('3,4');
});
});
// =========================================================================
// Walkability
// =========================================================================
describe('walkability', () => {
it('should set and get walkability', () => {
grid.setWalkable(5, 5, false);
expect(grid.isWalkable(5, 5)).toBe(false);
grid.setWalkable(5, 5, true);
expect(grid.isWalkable(5, 5)).toBe(true);
});
it('should return false for out of bounds', () => {
expect(grid.isWalkable(-1, 0)).toBe(false);
expect(grid.isWalkable(100, 100)).toBe(false);
});
it('should handle setWalkable on invalid position gracefully', () => {
grid.setWalkable(-1, -1, false); // Should not throw
});
});
// =========================================================================
// Cost
// =========================================================================
describe('cost', () => {
it('should set and get cost', () => {
grid.setCost(5, 5, 2.5);
const node = grid.getNodeAt(5, 5);
expect(node?.cost).toBe(2.5);
});
it('should default cost to 1', () => {
const node = grid.getNodeAt(5, 5);
expect(node?.cost).toBe(1);
});
});
// =========================================================================
// Neighbors (8-direction)
// =========================================================================
describe('getNeighbors (8-direction)', () => {
it('should return 8 neighbors for center node', () => {
const node = grid.getNodeAt(5, 5)!;
const neighbors = grid.getNeighbors(node);
expect(neighbors.length).toBe(8);
});
it('should return 3 neighbors for corner', () => {
const node = grid.getNodeAt(0, 0)!;
const neighbors = grid.getNeighbors(node);
expect(neighbors.length).toBe(3);
});
it('should return 5 neighbors for edge', () => {
const node = grid.getNodeAt(5, 0)!;
const neighbors = grid.getNeighbors(node);
expect(neighbors.length).toBe(5);
});
it('should not include blocked neighbors', () => {
grid.setWalkable(6, 5, false);
const node = grid.getNodeAt(5, 5)!;
const neighbors = grid.getNeighbors(node);
// 8 - 1 blocked - 2 diagonals (corner cutting) = 5
expect(neighbors.length).toBe(5);
expect(neighbors.find(n => n.x === 6 && n.y === 5)).toBeUndefined();
});
it('should avoid corner cutting by default', () => {
// Block horizontal neighbor
grid.setWalkable(6, 5, false);
const node = grid.getNodeAt(5, 5)!;
const neighbors = grid.getNeighbors(node);
// Should not include diagonal (6,4) and (6,6) due to corner cutting
expect(neighbors.find(n => n.x === 6 && n.y === 4)).toBeUndefined();
expect(neighbors.find(n => n.x === 6 && n.y === 6)).toBeUndefined();
});
});
// =========================================================================
// Neighbors (4-direction)
// =========================================================================
describe('getNeighbors (4-direction)', () => {
let grid4: GridMap;
beforeEach(() => {
grid4 = new GridMap(10, 10, { allowDiagonal: false });
});
it('should return 4 neighbors for center node', () => {
const node = grid4.getNodeAt(5, 5)!;
const neighbors = grid4.getNeighbors(node);
expect(neighbors.length).toBe(4);
});
it('should return 2 neighbors for corner', () => {
const node = grid4.getNodeAt(0, 0)!;
const neighbors = grid4.getNeighbors(node);
expect(neighbors.length).toBe(2);
});
it('should return 3 neighbors for edge', () => {
const node = grid4.getNodeAt(5, 0)!;
const neighbors = grid4.getNeighbors(node);
expect(neighbors.length).toBe(3);
});
});
// =========================================================================
// Movement Cost
// =========================================================================
describe('getMovementCost', () => {
it('should return 1 for cardinal movement', () => {
const from = grid.getNodeAt(5, 5)!;
const to = grid.getNodeAt(6, 5)!;
expect(grid.getMovementCost(from, to)).toBe(1);
});
it('should return sqrt(2) for diagonal movement', () => {
const from = grid.getNodeAt(5, 5)!;
const to = grid.getNodeAt(6, 6)!;
expect(grid.getMovementCost(from, to)).toBeCloseTo(Math.SQRT2, 10);
});
it('should factor in destination cost', () => {
grid.setCost(6, 5, 2);
const from = grid.getNodeAt(5, 5)!;
const to = grid.getNodeAt(6, 5)!;
expect(grid.getMovementCost(from, to)).toBe(2);
});
});
// =========================================================================
// Load from Array
// =========================================================================
describe('loadFromArray', () => {
it('should load walkability from 2D array', () => {
const data = [
[0, 0, 1],
[0, 1, 0],
[1, 0, 0]
];
const small = new GridMap(3, 3);
small.loadFromArray(data);
expect(small.isWalkable(0, 0)).toBe(true);
expect(small.isWalkable(2, 0)).toBe(false);
expect(small.isWalkable(1, 1)).toBe(false);
expect(small.isWalkable(0, 2)).toBe(false);
});
it('should handle partial data', () => {
const data = [[0, 1]];
grid.loadFromArray(data);
expect(grid.isWalkable(0, 0)).toBe(true);
expect(grid.isWalkable(1, 0)).toBe(false);
});
});
// =========================================================================
// Load from String
// =========================================================================
describe('loadFromString', () => {
it('should load walkability from string', () => {
const mapStr = `
..#
.#.
#..`.trim();
const small = new GridMap(3, 3);
small.loadFromString(mapStr);
expect(small.isWalkable(0, 0)).toBe(true);
expect(small.isWalkable(2, 0)).toBe(false);
expect(small.isWalkable(1, 1)).toBe(false);
expect(small.isWalkable(0, 2)).toBe(false);
});
});
// =========================================================================
// toString
// =========================================================================
describe('toString', () => {
it('should export grid as string', () => {
const small = new GridMap(3, 2);
small.setWalkable(1, 0, false);
const expected = '.#.\n...\n';
expect(small.toString()).toBe(expected);
});
});
// =========================================================================
// Reset
// =========================================================================
describe('reset', () => {
it('should reset all nodes to walkable', () => {
grid.setWalkable(5, 5, false);
grid.setCost(3, 3, 5);
grid.reset();
expect(grid.isWalkable(5, 5)).toBe(true);
expect(grid.getNodeAt(3, 3)?.cost).toBe(1);
});
});
// =========================================================================
// setRectWalkable
// =========================================================================
describe('setRectWalkable', () => {
it('should set rectangle region walkability', () => {
grid.setRectWalkable(2, 2, 3, 3, false);
for (let y = 2; y < 5; y++) {
for (let x = 2; x < 5; x++) {
expect(grid.isWalkable(x, y)).toBe(false);
}
}
// Outside should still be walkable
expect(grid.isWalkable(1, 2)).toBe(true);
expect(grid.isWalkable(5, 2)).toBe(true);
});
});
// =========================================================================
// Bounds Checking
// =========================================================================
describe('isInBounds', () => {
it('should return true for valid coordinates', () => {
expect(grid.isInBounds(0, 0)).toBe(true);
expect(grid.isInBounds(9, 9)).toBe(true);
expect(grid.isInBounds(5, 5)).toBe(true);
});
it('should return false for invalid coordinates', () => {
expect(grid.isInBounds(-1, 0)).toBe(false);
expect(grid.isInBounds(0, -1)).toBe(false);
expect(grid.isInBounds(10, 0)).toBe(false);
expect(grid.isInBounds(0, 10)).toBe(false);
});
});
});
describe('GridNode', () => {
it('should create node with correct properties', () => {
const node = new GridNode(3, 4, true, 2);
expect(node.x).toBe(3);
expect(node.y).toBe(4);
expect(node.walkable).toBe(true);
expect(node.cost).toBe(2);
expect(node.id).toBe('3,4');
expect(node.position.x).toBe(3);
expect(node.position.y).toBe(4);
});
it('should default to walkable with cost 1', () => {
const node = new GridNode(0, 0);
expect(node.walkable).toBe(true);
expect(node.cost).toBe(1);
});
});
describe('Direction Constants', () => {
it('DIRECTIONS_4 should have 4 cardinal directions', () => {
expect(DIRECTIONS_4.length).toBe(4);
});
it('DIRECTIONS_8 should have 8 directions', () => {
expect(DIRECTIONS_8.length).toBe(8);
});
});

View File

@@ -0,0 +1,386 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NavMesh, createNavMesh } from '../../src/navmesh/NavMesh';
import { createPoint } from '../../src/core/IPathfinding';
describe('NavMesh', () => {
let navmesh: NavMesh;
beforeEach(() => {
navmesh = new NavMesh();
});
// =========================================================================
// Polygon Management
// =========================================================================
describe('polygon management', () => {
it('should add polygon and return id', () => {
const id = navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
expect(id).toBe(0);
expect(navmesh.polygonCount).toBe(1);
});
it('should add multiple polygons with incremental ids', () => {
const id1 = navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(5, 10)
]);
const id2 = navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(15, 10)
]);
expect(id1).toBe(0);
expect(id2).toBe(1);
expect(navmesh.polygonCount).toBe(2);
});
it('should get all polygons', () => {
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(5, 10)
]);
navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(15, 10)
]);
const polygons = navmesh.getPolygons();
expect(polygons.length).toBe(2);
});
it('should clear all polygons', () => {
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(5, 10)
]);
navmesh.clear();
expect(navmesh.polygonCount).toBe(0);
});
});
// =========================================================================
// Point in Polygon
// =========================================================================
describe('findPolygonAt', () => {
beforeEach(() => {
// Square from (0,0) to (10,10)
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
});
it('should find polygon containing point', () => {
const polygon = navmesh.findPolygonAt(5, 5);
expect(polygon).not.toBeNull();
expect(polygon?.id).toBe(0);
});
it('should return null for point outside', () => {
expect(navmesh.findPolygonAt(-1, 5)).toBeNull();
expect(navmesh.findPolygonAt(15, 5)).toBeNull();
});
it('should handle point on edge', () => {
const polygon = navmesh.findPolygonAt(0, 5);
// Edge behavior may vary, but should not crash
expect(polygon === null || polygon.id === 0).toBe(true);
});
});
// =========================================================================
// Walkability
// =========================================================================
describe('isWalkable', () => {
beforeEach(() => {
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
});
it('should return true for point in polygon', () => {
expect(navmesh.isWalkable(5, 5)).toBe(true);
});
it('should return false for point outside', () => {
expect(navmesh.isWalkable(15, 5)).toBe(false);
});
});
// =========================================================================
// Connections
// =========================================================================
describe('connections', () => {
it('should manually set connection between polygons', () => {
// Two adjacent squares
const id1 = navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
const id2 = navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(20, 10),
createPoint(10, 10)
]);
navmesh.setConnection(id1, id2, {
left: createPoint(10, 0),
right: createPoint(10, 10)
});
const polygons = navmesh.getPolygons();
const poly1 = polygons.find(p => p.id === id1);
expect(poly1?.neighbors).toContain(id2);
});
it('should auto-detect shared edges with build()', () => {
// Two adjacent squares sharing edge at x=10
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(20, 10),
createPoint(10, 10)
]);
navmesh.build();
const polygons = navmesh.getPolygons();
expect(polygons[0].neighbors).toContain(1);
expect(polygons[1].neighbors).toContain(0);
});
});
// =========================================================================
// Pathfinding
// =========================================================================
describe('findPath', () => {
beforeEach(() => {
// Create 3 connected squares
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(20, 10),
createPoint(10, 10)
]);
navmesh.addPolygon([
createPoint(20, 0),
createPoint(30, 0),
createPoint(30, 10),
createPoint(20, 10)
]);
navmesh.build();
});
it('should find path within same polygon', () => {
const result = navmesh.findPath(1, 1, 8, 8);
expect(result.found).toBe(true);
expect(result.path.length).toBe(2);
expect(result.path[0]).toEqual(createPoint(1, 1));
expect(result.path[1]).toEqual(createPoint(8, 8));
});
it('should find path across polygons', () => {
const result = navmesh.findPath(5, 5, 25, 5);
expect(result.found).toBe(true);
expect(result.path.length).toBeGreaterThanOrEqual(2);
expect(result.path[0]).toEqual(createPoint(5, 5));
expect(result.path[result.path.length - 1]).toEqual(createPoint(25, 5));
});
it('should return empty path when start is outside', () => {
const result = navmesh.findPath(-5, 5, 15, 5);
expect(result.found).toBe(false);
});
it('should return empty path when end is outside', () => {
const result = navmesh.findPath(5, 5, 50, 5);
expect(result.found).toBe(false);
});
it('should calculate path cost', () => {
const result = navmesh.findPath(5, 5, 25, 5);
expect(result.found).toBe(true);
expect(result.cost).toBeGreaterThan(0);
});
it('should track nodes searched', () => {
const result = navmesh.findPath(5, 5, 25, 5);
expect(result.nodesSearched).toBeGreaterThan(0);
});
});
// =========================================================================
// IPathfindingMap Interface
// =========================================================================
describe('IPathfindingMap interface', () => {
beforeEach(() => {
// Two adjacent squares with shared edge at x=10
const id1 = navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
const id2 = navmesh.addPolygon([
createPoint(10, 0),
createPoint(20, 0),
createPoint(20, 10),
createPoint(10, 10)
]);
// Manual connection to ensure proper setup
navmesh.setConnection(id1, id2, {
left: createPoint(10, 0),
right: createPoint(10, 10)
});
});
it('should return node at position', () => {
const node = navmesh.getNodeAt(5, 5);
expect(node).not.toBeNull();
expect(node?.id).toBe(0);
});
it('should return null for position outside', () => {
const node = navmesh.getNodeAt(50, 50);
expect(node).toBeNull();
});
it('should get neighbors from polygon directly', () => {
// NavMeshNode holds a reference to the original polygon,
// so we check via the polygons map which is updated by setConnection
const polygons = navmesh.getPolygons();
const poly0 = polygons.find(p => p.id === 0);
expect(poly0).toBeDefined();
expect(poly0!.neighbors).toContain(1);
});
it('should calculate heuristic', () => {
const a = createPoint(0, 0);
const b = createPoint(3, 4);
expect(navmesh.heuristic(a, b)).toBe(5); // Euclidean
});
it('should calculate movement cost', () => {
const node1 = navmesh.getNodeAt(5, 5)!;
const node2 = navmesh.getNodeAt(15, 5)!;
const cost = navmesh.getMovementCost(node1, node2);
expect(cost).toBeGreaterThan(0);
});
});
// =========================================================================
// Complex Scenarios
// =========================================================================
describe('complex scenarios', () => {
it('should handle L-shaped navmesh with manual connections', () => {
// Horizontal part
const id1 = navmesh.addPolygon([
createPoint(0, 0),
createPoint(30, 0),
createPoint(30, 10),
createPoint(0, 10)
]);
// Vertical part (shares partial edge, needs manual connection)
const id2 = navmesh.addPolygon([
createPoint(0, 10),
createPoint(10, 10),
createPoint(10, 30),
createPoint(0, 30)
]);
// Manual connection since edges don't match exactly
navmesh.setConnection(id1, id2, {
left: createPoint(0, 10),
right: createPoint(10, 10)
});
const result = navmesh.findPath(25, 5, 5, 25);
expect(result.found).toBe(true);
});
it('should handle disconnected areas', () => {
// Area 1
navmesh.addPolygon([
createPoint(0, 0),
createPoint(10, 0),
createPoint(10, 10),
createPoint(0, 10)
]);
// Area 2 (disconnected)
navmesh.addPolygon([
createPoint(50, 50),
createPoint(60, 50),
createPoint(60, 60),
createPoint(50, 60)
]);
navmesh.build();
const result = navmesh.findPath(5, 5, 55, 55);
expect(result.found).toBe(false);
});
});
// =========================================================================
// Factory Function
// =========================================================================
describe('createNavMesh', () => {
it('should create empty navmesh', () => {
const nm = createNavMesh();
expect(nm).toBeInstanceOf(NavMesh);
expect(nm.polygonCount).toBe(0);
});
});
});

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
bresenhamLineOfSight,
raycastLineOfSight,
LineOfSightSmoother,
CatmullRomSmoother,
CombinedSmoother
} from '../../src/smoothing/PathSmoother';
import { GridMap } from '../../src/grid/GridMap';
import { createPoint, type IPoint } from '../../src/core/IPathfinding';
describe('Line of Sight Functions', () => {
let grid: GridMap;
beforeEach(() => {
grid = new GridMap(10, 10);
});
// =========================================================================
// bresenhamLineOfSight
// =========================================================================
describe('bresenhamLineOfSight', () => {
it('should return true for clear line', () => {
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(true);
});
it('should return true for same point', () => {
expect(bresenhamLineOfSight(5, 5, 5, 5, grid)).toBe(true);
});
it('should return true for horizontal line', () => {
expect(bresenhamLineOfSight(0, 5, 9, 5, grid)).toBe(true);
});
it('should return true for vertical line', () => {
expect(bresenhamLineOfSight(5, 0, 5, 9, grid)).toBe(true);
});
it('should return false when blocked', () => {
grid.setWalkable(5, 5, false);
expect(bresenhamLineOfSight(0, 0, 9, 9, grid)).toBe(false);
});
it('should return false when start is blocked', () => {
grid.setWalkable(0, 0, false);
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
});
it('should return false when end is blocked', () => {
grid.setWalkable(5, 5, false);
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
});
it('should detect obstacle in middle', () => {
grid.setWalkable(3, 3, false);
expect(bresenhamLineOfSight(0, 0, 6, 6, grid)).toBe(false);
});
});
// =========================================================================
// raycastLineOfSight
// =========================================================================
describe('raycastLineOfSight', () => {
it('should return true for clear line', () => {
expect(raycastLineOfSight(0, 0, 5, 5, grid)).toBe(true);
});
it('should return true for same point', () => {
expect(raycastLineOfSight(5, 5, 5, 5, grid)).toBe(true);
});
it('should return false when blocked', () => {
grid.setWalkable(5, 5, false);
expect(raycastLineOfSight(0, 0, 9, 9, grid)).toBe(false);
});
it('should work with custom step size', () => {
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(true);
grid.setWalkable(2, 2, false);
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(false);
});
});
});
describe('LineOfSightSmoother', () => {
let grid: GridMap;
let smoother: LineOfSightSmoother;
beforeEach(() => {
grid = new GridMap(20, 20);
smoother = new LineOfSightSmoother();
});
it('should return same path for 2 or fewer points', () => {
const path1: IPoint[] = [createPoint(0, 0)];
expect(smoother.smooth(path1, grid)).toEqual(path1);
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
expect(smoother.smooth(path2, grid)).toEqual(path2);
});
it('should remove unnecessary waypoints on straight line', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(1, 0),
createPoint(2, 0),
createPoint(3, 0),
createPoint(4, 0),
createPoint(5, 0)
];
const result = smoother.smooth(path, grid);
expect(result.length).toBe(2);
expect(result[0]).toEqual(createPoint(0, 0));
expect(result[1]).toEqual(createPoint(5, 0));
});
it('should remove unnecessary waypoints on diagonal', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(1, 1),
createPoint(2, 2),
createPoint(3, 3),
createPoint(4, 4),
createPoint(5, 5)
];
const result = smoother.smooth(path, grid);
expect(result.length).toBe(2);
expect(result[0]).toEqual(createPoint(0, 0));
expect(result[1]).toEqual(createPoint(5, 5));
});
it('should keep waypoints around obstacles', () => {
// Create obstacle
grid.setWalkable(5, 5, false);
const path: IPoint[] = [
createPoint(0, 0),
createPoint(4, 5),
createPoint(6, 5),
createPoint(10, 10)
];
const result = smoother.smooth(path, grid);
// Should keep at least start, one waypoint near obstacle, and end
expect(result.length).toBeGreaterThanOrEqual(3);
});
it('should use custom line of sight function', () => {
const customLOS = (x1: number, y1: number, x2: number, y2: number) => {
// Always blocked
return false;
};
const customSmoother = new LineOfSightSmoother(customLOS);
const path: IPoint[] = [
createPoint(0, 0),
createPoint(1, 1),
createPoint(2, 2)
];
const result = customSmoother.smooth(path, grid);
// Should not simplify because LOS always fails
expect(result).toEqual(path);
});
});
describe('CatmullRomSmoother', () => {
let grid: GridMap;
let smoother: CatmullRomSmoother;
beforeEach(() => {
grid = new GridMap(20, 20);
smoother = new CatmullRomSmoother(5, 0.5);
});
it('should return same path for 2 or fewer points', () => {
const path1: IPoint[] = [createPoint(0, 0)];
expect(smoother.smooth(path1, grid)).toEqual(path1);
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
expect(smoother.smooth(path2, grid)).toEqual(path2);
});
it('should add interpolation points', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(5, 0),
createPoint(10, 0)
];
const result = smoother.smooth(path, grid);
// Should have more points due to interpolation
expect(result.length).toBeGreaterThan(path.length);
});
it('should preserve start and end points', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(5, 5),
createPoint(10, 0)
];
const result = smoother.smooth(path, grid);
expect(result[0].x).toBeCloseTo(0, 1);
expect(result[0].y).toBeCloseTo(0, 1);
expect(result[result.length - 1]).toEqual(createPoint(10, 0));
});
it('should create smooth curve', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(5, 5),
createPoint(10, 0)
];
const result = smoother.smooth(path, grid);
// Check that middle points are near the original waypoint
const middlePoints = result.filter(p =>
Math.abs(p.x - 5) < 2 && Math.abs(p.y - 5) < 2
);
expect(middlePoints.length).toBeGreaterThan(0);
});
it('should work with different segment counts', () => {
const smootherLow = new CatmullRomSmoother(2);
const smootherHigh = new CatmullRomSmoother(10);
const path: IPoint[] = [
createPoint(0, 0),
createPoint(5, 5),
createPoint(10, 0)
];
const resultLow = smootherLow.smooth(path, grid);
const resultHigh = smootherHigh.smooth(path, grid);
expect(resultHigh.length).toBeGreaterThan(resultLow.length);
});
});
describe('CombinedSmoother', () => {
let grid: GridMap;
let smoother: CombinedSmoother;
beforeEach(() => {
grid = new GridMap(20, 20);
smoother = new CombinedSmoother(5, 0.5);
});
it('should first simplify then curve smooth', () => {
// Path with redundant points
const path: IPoint[] = [
createPoint(0, 0),
createPoint(1, 0),
createPoint(2, 0),
createPoint(3, 0),
createPoint(4, 0),
createPoint(5, 0),
createPoint(6, 3),
createPoint(7, 6),
createPoint(8, 6),
createPoint(9, 6),
createPoint(10, 6)
];
const result = smoother.smooth(path, grid);
// Should have smoothed the path
expect(result.length).toBeGreaterThan(0);
expect(result[0].x).toBeCloseTo(0, 1);
expect(result[result.length - 1]).toEqual(createPoint(10, 6));
});
it('should handle simple path', () => {
const path: IPoint[] = [
createPoint(0, 0),
createPoint(10, 10)
];
const result = smoother.smooth(path, grid);
expect(result.length).toBe(2);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/pathfinding",
"version": "1.1.0",
"version": "3.0.0",
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
"type": "module",
"main": "./dist/index.js",
@@ -20,6 +20,8 @@
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rimraf dist"
},
"devDependencies": {
@@ -27,7 +29,8 @@
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/blueprint": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"vitest": "^2.1.9"
},
"peerDependencies": {
"@esengine/ecs-framework": "workspace:*",

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['__tests__/**/*.test.ts'],
},
});

View File

@@ -1,5 +1,29 @@
# @esengine/procgen
## 3.0.0
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
- @esengine/blueprint@3.0.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/procgen",
"version": "1.0.3",
"version": "3.0.0",
"description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,170 @@
# @esengine/server
## 3.0.0
### Minor Changes
- feat(ecs): 添加 @NetworkEntity 装饰器,支持自动广播实体生成/销毁
### 新功能
**@NetworkEntity 装饰器**
- 标记组件为网络实体,自动广播 spawn/despawn 消息
- 支持 `autoSpawn``autoDespawn` 配置选项
- 通过事件系统(`ECSEventType.COMPONENT_ADDED` / `ECSEventType.ENTITY_DESTROYED`)实现
**ECSRoom 增强**
- 新增 `enableAutoNetworkEntity` 配置选项(默认启用)
- 自动监听组件添加和实体销毁事件
- 简化 GameRoom 实现,无需手动回调
### 改进
**Entity 事件**
- `Entity.destroy()` 现在发出 `entity:destroyed` 事件
- `Entity.active` 变化时发出 `entity:enabled` / `entity:disabled` 事件
- 使用 `ECSEventType` 常量替代硬编码字符串
### 使用示例
```typescript
import { Component, ECSComponent, sync, NetworkEntity } from '@esengine/ecs-framework';
@ECSComponent('Enemy')
@NetworkEntity('Enemy')
class EnemyComponent extends Component {
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
// 服务端
const entity = scene.createEntity('Enemy');
entity.addComponent(new EnemyComponent()); // 自动广播 spawn
entity.destroy(); // 自动广播 despawn
```
### Patch Changes
- Updated dependencies []:
- @esengine/ecs-framework@2.6.0
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/server",
"version": "1.1.4",
"version": "3.0.0",
"description": "Game server framework for ESEngine with file-based routing",
"type": "module",
"main": "./dist/index.js",
@@ -10,6 +10,26 @@
".": {
"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": [
@@ -21,20 +41,36 @@
"build:watch": "tsup --watch",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
"clean": "rimraf dist",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@esengine/rpc": "workspace:*"
},
"peerDependencies": {
"ws": ">=8.0.0"
"ws": ">=8.0.0",
"jsonwebtoken": ">=9.0.0",
"@esengine/ecs-framework": ">=2.6.0"
},
"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",
"vitest": "^2.0.0",
"ws": "^8.18.0"
},
"publishConfig": {

View File

@@ -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');
});
});

View 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);
});
});

View 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']);
});
});

View 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');
});
});

View 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;
}

View 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';

View 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;
};
}

View 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;
};
}

View 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';

View 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';

View 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;
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
/**
* @zh 认证提供者接口
* @en Authentication provider interface
*/
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';

View 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);
}

View File

@@ -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);
}

View 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';

Some files were not shown because too many files have changed in this diff Show More