Compare commits
5 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18af48a0fc | ||
|
|
d4cef828e1 | ||
|
|
2d46ccf896 | ||
|
|
fb8bde6485 | ||
|
|
30437dc5d5 |
@@ -268,6 +268,17 @@ export default defineConfig({
|
||||
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '世界流式加载',
|
||||
translations: { en: 'World Streaming' },
|
||||
items: [
|
||||
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
|
||||
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
|
||||
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
|
||||
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
|
||||
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "Area of Interest (AOI)"
|
||||
description: "View range based network entity filtering"
|
||||
---
|
||||
|
||||
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` provides grid-based area of interest management.
|
||||
|
||||
### Enable AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // Grid cell size
|
||||
defaultViewRange: 500, // Default view range
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Adding Observers
|
||||
|
||||
Each player that needs to receive sync data must be added as an observer:
|
||||
|
||||
```typescript
|
||||
// Add observer when player joins
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... setup components
|
||||
|
||||
// Add player as AOI observer
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // Network ID
|
||||
spawn.pos.x, // Initial X position
|
||||
spawn.pos.y, // Initial Y position
|
||||
600 // View range (optional)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// Remove observer when player leaves
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### Updating Observer Position
|
||||
|
||||
When a player moves, update their AOI position:
|
||||
|
||||
```typescript
|
||||
// Update in game loop or sync callback
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `cellSize` | `number` | 100 | Grid cell size |
|
||||
| `defaultViewRange` | `number` | 500 | Default view range |
|
||||
| `enabled` | `boolean` | true | Whether AOI is enabled |
|
||||
|
||||
### Grid Size Recommendations
|
||||
|
||||
Grid size should be set based on game view range:
|
||||
|
||||
```typescript
|
||||
// Recommendation: cellSize = defaultViewRange / 3 to / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // Grid is about 1/5 of view range
|
||||
}
|
||||
```
|
||||
|
||||
## Query Interface
|
||||
|
||||
### Get Visible Entities
|
||||
|
||||
```typescript
|
||||
// Get all entities visible to player
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### Check Visibility
|
||||
|
||||
```typescript
|
||||
// Check if player can see an entity
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// Target is in view
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listening
|
||||
|
||||
The AOI system triggers events when entities enter/exit view:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// Can send entity's initial state here
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// Can cleanup resources here
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Filtering
|
||||
|
||||
AOI is most commonly used server-side to filter sync data for each client:
|
||||
|
||||
```typescript
|
||||
// Server-side example
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// Player joins
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// Player moves
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// Send sync data
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// Filter using AOI
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// Send only visible entities
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game World │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
|
||||
│ │ │ P │ ● │ │ │ P = Player │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
|
||||
│ │ │ │ E │ E │ │ ○ = View range │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ Player only sees E in view│
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ View range (circle): Contains 3 enemies │
|
||||
│ Grid optimization: Only check cells covered by view │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Grid Optimization
|
||||
|
||||
AOI uses spatial grid to accelerate queries:
|
||||
|
||||
1. **Add Entity**: Calculate grid cell based on position
|
||||
2. **View Detection**: Only check cells covered by view range
|
||||
3. **Move Update**: Update cell assignment when crossing cells
|
||||
4. **Event Trigger**: Detect enter/exit view
|
||||
|
||||
## Dynamic View Range
|
||||
|
||||
Different player types can have different view ranges:
|
||||
|
||||
```typescript
|
||||
// Regular player
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP player (larger view)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// Adjust view range at runtime
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Server-Side Usage
|
||||
|
||||
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
|
||||
|
||||
```typescript
|
||||
// Filter on server before sending
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. Edge Handling
|
||||
|
||||
Add buffer zone at view edge to prevent flickering:
|
||||
|
||||
```typescript
|
||||
// Add immediately when entering view
|
||||
// Remove with delay when exiting (keep for 1-2 extra seconds)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// Re-check if really exited
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Large Entities
|
||||
|
||||
Large entities (like bosses) may need special handling:
|
||||
|
||||
```typescript
|
||||
// Boss is always visible to everyone
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// Add boss entity
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Performance Considerations
|
||||
|
||||
```typescript
|
||||
// Large-scale game recommended config
|
||||
aoiConfig: {
|
||||
cellSize: 200, // Larger grid reduces cell count
|
||||
defaultViewRange: 800, // Set based on actual view
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// Get visible entities for specific player
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "State Delta Compression"
|
||||
description: "Reduce network bandwidth with incremental sync"
|
||||
---
|
||||
|
||||
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// Create compressor
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // Position change threshold
|
||||
rotationThreshold: 0.001, // Rotation change threshold (radians)
|
||||
velocityThreshold: 0.1, // Velocity change threshold
|
||||
fullSnapshotInterval: 60, // Full snapshot interval (frames)
|
||||
});
|
||||
|
||||
// Compress sync data
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData only contains changed fields
|
||||
|
||||
// Decompress delta data
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `positionThreshold` | `number` | 0.01 | Position change threshold |
|
||||
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
|
||||
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
|
||||
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
|
||||
|
||||
## Delta Flags
|
||||
|
||||
Bit flags indicate which fields have changed:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// Flag definitions
|
||||
DeltaFlags.NONE // 0 - No change
|
||||
DeltaFlags.POSITION // 1 - Position changed
|
||||
DeltaFlags.ROTATION // 2 - Rotation changed
|
||||
DeltaFlags.VELOCITY // 4 - Velocity changed
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
|
||||
DeltaFlags.CUSTOM // 16 - Custom data changed
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
### Full State
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Delta State
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // Change flags
|
||||
pos?: { x: number; y: number }; // Only present when POSITION flag set
|
||||
rot?: number; // Only present when ROTATION flag set
|
||||
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
|
||||
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
|
||||
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Frame 1 (full snapshot):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
Frame 2 (delta):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
|
||||
|
||||
Frame 3 (delta):
|
||||
Entity 1: flags=0 // No change, not sent
|
||||
|
||||
Frame 4 (delta):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
Frame 60 (forced full snapshot):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## Server-Side Usage
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Broadcast state updates
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// Compress data
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// Send delta data
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// Cleanup when player leaves
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Usage
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// Receive delta data
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// Decompress to full state
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// Apply state
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bandwidth Savings Example
|
||||
|
||||
Assume each entity has the following data:
|
||||
|
||||
| Field | Size (bytes) |
|
||||
|-------|-------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **Total** | **52** |
|
||||
|
||||
With delta compression:
|
||||
|
||||
| Scenario | Original | Compressed | Savings |
|
||||
|----------|----------|------------|---------|
|
||||
| Only position changed | 52 | 4+1+16 = 21 | 60% |
|
||||
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
|
||||
| Stationary | 52 | 0 | 100% |
|
||||
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## Forcing Full Snapshot
|
||||
|
||||
Some situations require sending full snapshots:
|
||||
|
||||
```typescript
|
||||
// When new player joins
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// This will send full state
|
||||
|
||||
// On reconnection
|
||||
compressor.clear(); // Clear history
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## Custom Data
|
||||
|
||||
Support for syncing custom game data:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Custom data is also delta compressed
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Thresholds
|
||||
|
||||
```typescript
|
||||
// High precision games (e.g., competitive)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// Casual games
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Adjust Full Snapshot Interval
|
||||
|
||||
```typescript
|
||||
// High reliability (unstable network)
|
||||
fullSnapshotInterval: 30, // Full snapshot every 30 frames
|
||||
|
||||
// Low bandwidth priority
|
||||
fullSnapshotInterval: 120, // Full snapshot every 120 frames
|
||||
```
|
||||
|
||||
### 3. Combine with AOI
|
||||
|
||||
```typescript
|
||||
// Filter with AOI first, then delta compress
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. Handle Entity Removal
|
||||
|
||||
```typescript
|
||||
// Clean up compressor state when entity despawns
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Features
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Game State │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI Filter │ ← Only process entities in view
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Delta Compress │ ← Only send changed fields
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Network Send │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// Check compression efficiency
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// View each entity's changes
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
|
||||
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
|
||||
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
||||
|
||||
## Service Tokens
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## Blueprint Nodes
|
||||
|
||||
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: "Client Prediction"
|
||||
description: "Local input prediction and server reconciliation"
|
||||
---
|
||||
|
||||
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // Movement speed (units/second)
|
||||
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
|
||||
reconciliationThreshold: 0.5, // Reconciliation threshold
|
||||
reconciliationSpeed: 10, // Reconciliation speed
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### Setting Up Local Player
|
||||
|
||||
After the local player entity spawns, set its network ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// Set local player for prediction
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### Sending Input
|
||||
|
||||
```typescript
|
||||
// Send movement input in game loop
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// Send action input
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prediction Configuration
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
|
||||
| `enabled` | `boolean` | true | Whether prediction is enabled |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
|
||||
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
├─ 1. Capture input (seq=1) │
|
||||
├─ 2. Predict movement locally │
|
||||
├─ 3. Send input to server ─────────►
|
||||
│ │
|
||||
├─ 4. Continue capturing (seq=2,3...) │
|
||||
├─ 5. Continue predicting │
|
||||
│ │
|
||||
│ ├─ 6. Process input (seq=1)
|
||||
│ │
|
||||
◄──────── 7. Return state (ackSeq=1) ─
|
||||
│ │
|
||||
├─ 8. Compare prediction with server │
|
||||
├─ 9. Replay inputs seq=2,3... │
|
||||
├─ 10. Smooth correction │
|
||||
│ │
|
||||
```
|
||||
|
||||
### Step by Step
|
||||
|
||||
1. **Input Capture**: Capture player input and assign sequence number
|
||||
2. **Local Prediction**: Immediately apply input to local state
|
||||
3. **Send Input**: Send input to server
|
||||
4. **Cache Input**: Save input for later reconciliation
|
||||
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
|
||||
6. **State Comparison**: Compare predicted state with server state
|
||||
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
|
||||
8. **Smooth Correction**: Interpolate smoothly to correct position
|
||||
|
||||
## Low-Level API
|
||||
|
||||
For fine-grained control, use the `ClientPrediction` class directly:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// Define state type
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// Define input type
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// Define predictor
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Create client prediction
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// Record input and get predicted state
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// Get input to send
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// Reconcile with server state
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// Get correction offset
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## Enable/Disable Prediction
|
||||
|
||||
```typescript
|
||||
// Toggle prediction at runtime
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// Check prediction status
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Set Appropriate Reconciliation Threshold
|
||||
|
||||
```typescript
|
||||
// Action games: lower threshold, more precise
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// Casual games: higher threshold, smoother
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Prediction Only for Local Player
|
||||
|
||||
Remote players should use interpolation, not prediction:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// Use prediction system
|
||||
} else {
|
||||
// Use NetworkSyncSystem interpolation
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle High Latency
|
||||
|
||||
```typescript
|
||||
// High latency network: increase buffer
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // Increase buffer
|
||||
reconciliationSpeed: 5, // Slower correction
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Deterministic Prediction
|
||||
|
||||
Ensure client and server use the same physics calculations:
|
||||
|
||||
```typescript
|
||||
// Use fixed timestep
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// Use fixed timestep instead of actual deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```typescript
|
||||
// Get prediction system instance
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
261
docs/src/content/docs/en/modules/transaction/core.md
Normal 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}`);
|
||||
}
|
||||
```
|
||||
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal 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 });
|
||||
}
|
||||
```
|
||||
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
238
docs/src/content/docs/en/modules/transaction/index.md
Normal 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) });
|
||||
}
|
||||
```
|
||||
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal 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,
|
||||
}));
|
||||
```
|
||||
215
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
215
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
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 {
|
||||
// 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.
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
import { RedisStorage } from '@esengine/transaction';
|
||||
|
||||
const redis = new Redis('redis://localhost:6379');
|
||||
|
||||
const storage = new RedisStorage({
|
||||
client: redis,
|
||||
prefix: 'tx:', // Key prefix
|
||||
transactionTTL: 86400, // Transaction log TTL (seconds)
|
||||
});
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
```typescript
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoStorage } from '@esengine/transaction';
|
||||
|
||||
const client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
const db = client.db('game');
|
||||
|
||||
const storage = new MongoStorage({
|
||||
db,
|
||||
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 });
|
||||
```
|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
@@ -35,6 +35,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
||||
| 模块 | 包名 | 描述 |
|
||||
|------|------|------|
|
||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
283
docs/src/content/docs/modules/network/aoi.md
Normal file
283
docs/src/content/docs/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: "兴趣区域管理 (AOI)"
|
||||
description: "基于视野范围的网络实体过滤"
|
||||
---
|
||||
|
||||
AOI(Area of Interest,兴趣区域)是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体,可以大幅减少网络流量。
|
||||
|
||||
## NetworkAOISystem
|
||||
|
||||
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
|
||||
|
||||
### 启用 AOI
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enableAOI: true,
|
||||
aoiConfig: {
|
||||
cellSize: 100, // 网格单元大小
|
||||
defaultViewRange: 500, // 默认视野范围
|
||||
enabled: true,
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 添加观察者
|
||||
|
||||
每个需要接收同步数据的玩家都需要作为观察者添加:
|
||||
|
||||
```typescript
|
||||
// 玩家加入时添加观察者
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
// ... 设置组件
|
||||
|
||||
// 将玩家添加为 AOI 观察者
|
||||
networkPlugin.addAOIObserver(
|
||||
spawn.netId, // 网络 ID
|
||||
spawn.pos.x, // 初始 X 位置
|
||||
spawn.pos.y, // 初始 Y 位置
|
||||
600 // 视野范围(可选)
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
// 玩家离开时移除观察者
|
||||
networkPlugin.removeAOIObserver(playerNetId);
|
||||
```
|
||||
|
||||
### 更新观察者位置
|
||||
|
||||
当玩家移动时,需要更新其 AOI 位置:
|
||||
|
||||
```typescript
|
||||
// 在游戏循环或同步回调中更新
|
||||
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||
```
|
||||
|
||||
## AOI 配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `cellSize` | `number` | 100 | 网格单元大小 |
|
||||
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
|
||||
| `enabled` | `boolean` | true | 是否启用 AOI |
|
||||
|
||||
### 网格大小建议
|
||||
|
||||
网格大小应根据游戏视野范围设置:
|
||||
|
||||
```typescript
|
||||
// 建议:cellSize = defaultViewRange / 3 到 / 5
|
||||
aoiConfig: {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500, // 网格大约是视野的 1/5
|
||||
}
|
||||
```
|
||||
|
||||
## 查询接口
|
||||
|
||||
### 获取可见实体
|
||||
|
||||
```typescript
|
||||
// 获取玩家能看到的所有实体
|
||||
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||
console.log('Visible entities:', visibleEntities);
|
||||
```
|
||||
|
||||
### 检查可见性
|
||||
|
||||
```typescript
|
||||
// 检查玩家是否能看到某个实体
|
||||
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||
// 目标在视野内
|
||||
}
|
||||
```
|
||||
|
||||
## 事件监听
|
||||
|
||||
AOI 系统会在实体进入/离开视野时触发事件:
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'enter') {
|
||||
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||
// 可以在这里发送实体的初始状态
|
||||
} else if (event.type === 'exit') {
|
||||
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||
// 可以在这里清理资源
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 服务器端过滤
|
||||
|
||||
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
|
||||
|
||||
```typescript
|
||||
// 服务器端示例
|
||||
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private aoiSystem = createNetworkAOISystem({
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
});
|
||||
|
||||
// 玩家加入
|
||||
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.addObserver(playerId, x, y);
|
||||
}
|
||||
|
||||
// 玩家移动
|
||||
onPlayerMove(playerId: number, x: number, y: number) {
|
||||
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||
}
|
||||
|
||||
// 发送同步数据
|
||||
broadcastSync(allEntities: EntitySyncState[]) {
|
||||
for (const playerId of this.players) {
|
||||
// 使用 AOI 过滤
|
||||
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||
playerId,
|
||||
allEntities
|
||||
);
|
||||
|
||||
// 只发送可见实体
|
||||
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 游戏世界 │
|
||||
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||
│ │ │ │ E │ │ │ │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
|
||||
│ │ │ P │ ● │ │ │ P = 玩家 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
|
||||
│ │ │ │ E │ E │ │ ○ = 视野范围 │
|
||||
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
|
||||
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||
│ │
|
||||
│ 视野范围(圆形):包含 3 个敌人 │
|
||||
│ 网格优化:只检查视野覆盖的网格单元 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 网格优化
|
||||
|
||||
AOI 使用空间网格加速查询:
|
||||
|
||||
1. **添加实体**:根据位置计算所在网格
|
||||
2. **视野检测**:只检查视野范围覆盖的网格
|
||||
3. **移动更新**:跨网格时更新网格归属
|
||||
4. **事件触发**:检测进入/离开视野
|
||||
|
||||
## 动态视野范围
|
||||
|
||||
可以为不同类型的玩家设置不同的视野:
|
||||
|
||||
```typescript
|
||||
// 普通玩家
|
||||
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||
|
||||
// VIP 玩家(更大视野)
|
||||
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||
|
||||
// 运行时调整视野
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
if (aoiSystem) {
|
||||
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 服务器端使用
|
||||
|
||||
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
|
||||
|
||||
```typescript
|
||||
// 服务器端过滤后再发送
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
sendToClient(playerId, filtered);
|
||||
```
|
||||
|
||||
### 2. 边界处理
|
||||
|
||||
在视野边缘添加缓冲区防止闪烁:
|
||||
|
||||
```typescript
|
||||
// 进入视野时立即添加
|
||||
// 离开视野时延迟移除(保持额外 1-2 秒)
|
||||
aoiSystem.addListener((event) => {
|
||||
if (event.type === 'exit') {
|
||||
setTimeout(() => {
|
||||
// 再次检查是否真的离开
|
||||
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||
removeFromClient(event.observerNetId, event.targetNetId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 大型实体
|
||||
|
||||
对于大型实体(如 Boss),可能需要特殊处理:
|
||||
|
||||
```typescript
|
||||
// Boss 总是对所有人可见
|
||||
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||
|
||||
// 添加 Boss 实体
|
||||
const bossState = entities.find(e => e.netId === bossNetId);
|
||||
if (bossState && !filtered.includes(bossState)) {
|
||||
filtered.push(bossState);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 性能考虑
|
||||
|
||||
```typescript
|
||||
// 大规模游戏建议配置
|
||||
aoiConfig: {
|
||||
cellSize: 200, // 较大的网格减少网格数量
|
||||
defaultViewRange: 800, // 根据实际视野设置
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const aoiSystem = networkPlugin.aoiSystem;
|
||||
|
||||
if (aoiSystem) {
|
||||
console.log('AOI enabled:', aoiSystem.enabled);
|
||||
console.log('Observer count:', aoiSystem.observerCount);
|
||||
|
||||
// 获取特定玩家的可见实体
|
||||
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||
console.log('Visible entities:', visible.length);
|
||||
}
|
||||
```
|
||||
316
docs/src/content/docs/modules/network/delta.md
Normal file
316
docs/src/content/docs/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: "状态增量压缩"
|
||||
description: "减少网络带宽的增量同步"
|
||||
---
|
||||
|
||||
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
|
||||
|
||||
## StateDeltaCompressor
|
||||
|
||||
`StateDeltaCompressor` 类用于压缩和解压状态增量。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||
|
||||
// 创建压缩器
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.01, // 位置变化阈值
|
||||
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
|
||||
velocityThreshold: 0.1, // 速度变化阈值
|
||||
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
|
||||
});
|
||||
|
||||
// 压缩同步数据
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||
],
|
||||
};
|
||||
|
||||
const deltaData = compressor.compress(syncData);
|
||||
// deltaData 只包含变化的字段
|
||||
|
||||
// 解压增量数据
|
||||
const fullData = compressor.decompress(deltaData);
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
|
||||
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
|
||||
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
|
||||
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
|
||||
|
||||
## 增量标志
|
||||
|
||||
使用位标志表示哪些字段发生了变化:
|
||||
|
||||
```typescript
|
||||
import { DeltaFlags } from '@esengine/network';
|
||||
|
||||
// 位标志定义
|
||||
DeltaFlags.NONE // 0 - 无变化
|
||||
DeltaFlags.POSITION // 1 - 位置变化
|
||||
DeltaFlags.ROTATION // 2 - 旋转变化
|
||||
DeltaFlags.VELOCITY // 4 - 速度变化
|
||||
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
|
||||
DeltaFlags.CUSTOM // 16 - 自定义数据变化
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 完整状态
|
||||
|
||||
```typescript
|
||||
interface EntitySyncState {
|
||||
netId: number;
|
||||
pos?: { x: number; y: number };
|
||||
rot?: number;
|
||||
vel?: { x: number; y: number };
|
||||
angVel?: number;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 增量状态
|
||||
|
||||
```typescript
|
||||
interface EntityDeltaState {
|
||||
netId: number;
|
||||
flags: number; // 变化标志位
|
||||
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
|
||||
rot?: number; // 仅在 ROTATION 标志时存在
|
||||
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
|
||||
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
|
||||
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
帧 1 (完整快照):
|
||||
Entity 1: pos=(100, 200), rot=0
|
||||
|
||||
帧 2 (增量):
|
||||
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
|
||||
|
||||
帧 3 (增量):
|
||||
Entity 1: flags=0 // 无变化,不发送
|
||||
|
||||
帧 4 (增量):
|
||||
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||
|
||||
帧 60 (强制完整快照):
|
||||
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||
```
|
||||
|
||||
## 服务器端使用
|
||||
|
||||
```typescript
|
||||
import { createStateDeltaCompressor } from '@esengine/network';
|
||||
|
||||
class GameServer {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 广播状态更新
|
||||
broadcastState(entities: EntitySyncState[]) {
|
||||
const syncData: SyncData = {
|
||||
frame: this.currentFrame,
|
||||
timestamp: Date.now(),
|
||||
entities,
|
||||
};
|
||||
|
||||
// 压缩数据
|
||||
const deltaData = this.compressor.compress(syncData);
|
||||
|
||||
// 发送增量数据
|
||||
this.broadcast('sync', deltaData);
|
||||
}
|
||||
|
||||
// 玩家离开时清理
|
||||
onPlayerLeave(netId: number) {
|
||||
this.compressor.removeEntity(netId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端使用
|
||||
|
||||
```typescript
|
||||
class GameClient {
|
||||
private compressor = createStateDeltaCompressor();
|
||||
|
||||
// 接收增量数据
|
||||
onSyncReceived(deltaData: DeltaSyncData) {
|
||||
// 解压为完整状态
|
||||
const fullData = this.compressor.decompress(deltaData);
|
||||
|
||||
// 应用状态
|
||||
for (const entity of fullData.entities) {
|
||||
this.applyEntityState(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 带宽节省示例
|
||||
|
||||
假设每个实体有以下数据:
|
||||
|
||||
| 字段 | 大小(字节) |
|
||||
|------|------------|
|
||||
| netId | 4 |
|
||||
| pos.x | 8 |
|
||||
| pos.y | 8 |
|
||||
| rot | 8 |
|
||||
| vel.x | 8 |
|
||||
| vel.y | 8 |
|
||||
| angVel | 8 |
|
||||
| **总计** | **52** |
|
||||
|
||||
使用增量压缩:
|
||||
|
||||
| 场景 | 原始 | 压缩后 | 节省 |
|
||||
|------|------|--------|------|
|
||||
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
|
||||
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
|
||||
| 静止不动 | 52 | 0 | 100% |
|
||||
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
|
||||
|
||||
## 强制完整快照
|
||||
|
||||
某些情况下需要发送完整快照:
|
||||
|
||||
```typescript
|
||||
// 新玩家加入时
|
||||
compressor.forceFullSnapshot();
|
||||
const data = compressor.compress(syncData);
|
||||
// 这次会发送完整状态
|
||||
|
||||
// 重连时
|
||||
compressor.clear(); // 清除历史状态
|
||||
compressor.forceFullSnapshot();
|
||||
```
|
||||
|
||||
## 自定义数据
|
||||
|
||||
支持同步自定义游戏数据:
|
||||
|
||||
```typescript
|
||||
const syncData: SyncData = {
|
||||
frame: 100,
|
||||
timestamp: Date.now(),
|
||||
entities: [
|
||||
{
|
||||
netId: 1,
|
||||
pos: { x: 100, y: 200 },
|
||||
custom: {
|
||||
health: 80,
|
||||
mana: 50,
|
||||
buffs: ['speed', 'shield'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 自定义数据也会进行增量压缩
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置阈值
|
||||
|
||||
```typescript
|
||||
// 高精度游戏(如竞技游戏)
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.001,
|
||||
rotationThreshold: 0.0001,
|
||||
});
|
||||
|
||||
// 普通游戏
|
||||
const compressor = createStateDeltaCompressor({
|
||||
positionThreshold: 0.1,
|
||||
rotationThreshold: 0.01,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 调整完整快照间隔
|
||||
|
||||
```typescript
|
||||
// 高可靠性(网络不稳定)
|
||||
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
|
||||
|
||||
// 低带宽优先
|
||||
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
|
||||
```
|
||||
|
||||
### 3. 配合 AOI 使用
|
||||
|
||||
```typescript
|
||||
// 先用 AOI 过滤,再用增量压缩
|
||||
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||
const deltaData = compressor.compress(syncData);
|
||||
```
|
||||
|
||||
### 4. 处理实体移除
|
||||
|
||||
```typescript
|
||||
// 实体销毁时清理压缩器状态
|
||||
function onEntityDespawn(netId: number) {
|
||||
compressor.removeEntity(netId);
|
||||
}
|
||||
```
|
||||
|
||||
## 与其他功能配合
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 游戏状态 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ AOI 过滤 │ ← 只处理视野内实体
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 增量压缩 │ ← 只发送变化的字段
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ 网络传输 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
const compressor = createStateDeltaCompressor();
|
||||
|
||||
// 检查压缩效果
|
||||
const original = syncData;
|
||||
const compressed = compressor.compress(original);
|
||||
|
||||
console.log('Original entities:', original.entities.length);
|
||||
console.log('Compressed entities:', compressed.entities.length);
|
||||
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||
|
||||
// 查看每个实体的变化
|
||||
for (const delta of compressed.entities) {
|
||||
console.log(`Entity ${delta.netId}:`, {
|
||||
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
||||
|
||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||
- [状态同步](/modules/network/sync/) - 插值、预测和快照
|
||||
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||
- [增量压缩](/modules/network/delta/) - 状态增量同步
|
||||
- [API 参考](/modules/network/api/) - 完整 API 文档
|
||||
|
||||
## 服务令牌
|
||||
@@ -159,10 +162,14 @@ import {
|
||||
NetworkServiceToken,
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from '@esengine/network';
|
||||
|
||||
const networkService = services.get(NetworkServiceToken);
|
||||
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||
```
|
||||
|
||||
## 蓝图节点
|
||||
|
||||
254
docs/src/content/docs/modules/network/prediction.md
Normal file
254
docs/src/content/docs/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: "客户端预测"
|
||||
description: "本地输入预测和服务器校正"
|
||||
---
|
||||
|
||||
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
|
||||
|
||||
## NetworkPredictionSystem
|
||||
|
||||
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { NetworkPlugin } from '@esengine/network';
|
||||
|
||||
const networkPlugin = new NetworkPlugin({
|
||||
enablePrediction: true,
|
||||
predictionConfig: {
|
||||
moveSpeed: 200, // 移动速度(单位/秒)
|
||||
maxUnacknowledgedInputs: 60, // 最大未确认输入数
|
||||
reconciliationThreshold: 0.5, // 校正阈值
|
||||
reconciliationSpeed: 10, // 校正速度
|
||||
}
|
||||
});
|
||||
|
||||
await Core.installPlugin(networkPlugin);
|
||||
```
|
||||
|
||||
### 设置本地玩家
|
||||
|
||||
当本地玩家实体生成后,需要设置其网络 ID:
|
||||
|
||||
```typescript
|
||||
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||
|
||||
const identity = entity.addComponent(new NetworkIdentity());
|
||||
identity.netId = spawn.netId;
|
||||
identity.ownerId = spawn.ownerId;
|
||||
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||
|
||||
entity.addComponent(new NetworkTransform());
|
||||
|
||||
// 设置本地玩家用于预测
|
||||
if (identity.bIsLocalPlayer) {
|
||||
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
```
|
||||
|
||||
### 发送输入
|
||||
|
||||
```typescript
|
||||
// 在游戏循环中发送移动输入
|
||||
function onUpdate() {
|
||||
const moveX = Input.getAxis('horizontal');
|
||||
const moveY = Input.getAxis('vertical');
|
||||
|
||||
if (moveX !== 0 || moveY !== 0) {
|
||||
networkPlugin.sendMoveInput(moveX, moveY);
|
||||
}
|
||||
|
||||
// 发送动作输入
|
||||
if (Input.isPressed('attack')) {
|
||||
networkPlugin.sendActionInput('attack');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 预测配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
|
||||
| `enabled` | `boolean` | true | 是否启用预测 |
|
||||
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
|
||||
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
|
||||
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
客户端 服务器
|
||||
│ │
|
||||
├─ 1. 捕获输入 (seq=1) │
|
||||
├─ 2. 本地预测移动 │
|
||||
├─ 3. 发送输入到服务器 ──────────────►
|
||||
│ │
|
||||
├─ 4. 继续捕获输入 (seq=2,3...) │
|
||||
├─ 5. 继续本地预测 │
|
||||
│ │
|
||||
│ ├─ 6. 处理输入 (seq=1)
|
||||
│ │
|
||||
◄──────── 7. 返回状态 (ackSeq=1) ────
|
||||
│ │
|
||||
├─ 8. 比较预测和服务器状态 │
|
||||
├─ 9. 重放 seq=2,3... 的输入 │
|
||||
├─ 10. 平滑校正到正确位置 │
|
||||
│ │
|
||||
```
|
||||
|
||||
### 步骤详解
|
||||
|
||||
1. **输入捕获**:捕获玩家输入并分配序列号
|
||||
2. **本地预测**:立即应用输入到本地状态
|
||||
3. **发送输入**:将输入发送到服务器
|
||||
4. **缓存输入**:保存输入用于后续校正
|
||||
5. **接收确认**:服务器返回权威状态和已确认序列号
|
||||
6. **状态比较**:比较预测状态和服务器状态
|
||||
7. **输入重放**:使用缓存的未确认输入重新计算状态
|
||||
8. **平滑校正**:平滑插值到正确位置
|
||||
|
||||
## 底层 API
|
||||
|
||||
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
|
||||
|
||||
```typescript
|
||||
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||
|
||||
// 定义状态类型
|
||||
interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// 定义输入类型
|
||||
interface PlayerInput {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
// 定义预测器
|
||||
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 创建客户端预测
|
||||
const prediction = createClientPrediction(predictor, {
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
});
|
||||
|
||||
// 记录输入并获取预测状态
|
||||
const input = { dx: 1, dy: 0 };
|
||||
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||
|
||||
// 获取要发送的输入
|
||||
const inputToSend = prediction.getInputToSend();
|
||||
|
||||
// 与服务器状态校正
|
||||
prediction.reconcile(
|
||||
serverState,
|
||||
serverAckSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// 获取校正偏移
|
||||
const offset = prediction.correctionOffset;
|
||||
```
|
||||
|
||||
## 启用/禁用预测
|
||||
|
||||
```typescript
|
||||
// 运行时切换预测
|
||||
networkPlugin.setPredictionEnabled(false);
|
||||
|
||||
// 检查预测状态
|
||||
if (networkPlugin.isPredictionEnabled) {
|
||||
console.log('Prediction is active');
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理设置校正阈值
|
||||
|
||||
```typescript
|
||||
// 动作游戏:较低阈值,更精确
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 0.1,
|
||||
}
|
||||
|
||||
// 休闲游戏:较高阈值,更平滑
|
||||
predictionConfig: {
|
||||
reconciliationThreshold: 1.0,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预测仅用于本地玩家
|
||||
|
||||
远程玩家应使用插值而非预测:
|
||||
|
||||
```typescript
|
||||
const identity = entity.getComponent(NetworkIdentity);
|
||||
|
||||
if (identity.bIsLocalPlayer) {
|
||||
// 使用预测系统
|
||||
} else {
|
||||
// 使用 NetworkSyncSystem 的插值
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 处理高延迟
|
||||
|
||||
```typescript
|
||||
// 高延迟网络增加缓冲
|
||||
predictionConfig: {
|
||||
maxUnacknowledgedInputs: 120, // 增加缓冲
|
||||
reconciliationSpeed: 5, // 减慢校正速度
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 确定性预测
|
||||
|
||||
确保客户端和服务器使用相同的物理计算:
|
||||
|
||||
```typescript
|
||||
// 使用固定时间步长
|
||||
const FIXED_DT = 1 / 60;
|
||||
|
||||
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||
// 使用固定时间步长而非实际 deltaTime
|
||||
return {
|
||||
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||
rotation: state.rotation,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 调试
|
||||
|
||||
```typescript
|
||||
// 获取预测系统实例
|
||||
const predictionSystem = networkPlugin.predictionSystem;
|
||||
|
||||
if (predictionSystem) {
|
||||
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||
}
|
||||
```
|
||||
261
docs/src/content/docs/modules/transaction/core.md
Normal file
261
docs/src/content/docs/modules/transaction/core.md
Normal 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}`);
|
||||
}
|
||||
```
|
||||
355
docs/src/content/docs/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/modules/transaction/distributed.md
Normal 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 });
|
||||
}
|
||||
```
|
||||
238
docs/src/content/docs/modules/transaction/index.md
Normal file
238
docs/src/content/docs/modules/transaction/index.md
Normal 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) });
|
||||
}
|
||||
```
|
||||
313
docs/src/content/docs/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/modules/transaction/operations.md
Normal 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,
|
||||
}));
|
||||
```
|
||||
215
docs/src/content/docs/modules/transaction/storage.md
Normal file
215
docs/src/content/docs/modules/transaction/storage.md
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: "存储层"
|
||||
description: "事务存储接口和实现:MemoryStorage、RedisStorage、MongoStorage"
|
||||
---
|
||||
|
||||
## 存储接口
|
||||
|
||||
所有存储实现都需要实现 `ITransactionStorage` 接口:
|
||||
|
||||
```typescript
|
||||
interface ITransactionStorage {
|
||||
// 分布式锁
|
||||
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 redis = new Redis('redis://localhost:6379');
|
||||
|
||||
const storage = new RedisStorage({
|
||||
client: redis,
|
||||
prefix: 'tx:', // 键前缀
|
||||
transactionTTL: 86400, // 事务日志过期时间(秒)
|
||||
});
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### 特点
|
||||
|
||||
- ✅ 高性能分布式锁
|
||||
- ✅ 快速读写
|
||||
- ✅ 支持 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 client = new MongoClient('mongodb://localhost:27017');
|
||||
await client.connect();
|
||||
const db = client.db('game');
|
||||
|
||||
const storage = new MongoStorage({
|
||||
db,
|
||||
transactionCollection: 'transactions', // 事务日志集合
|
||||
dataCollection: 'transaction_data', // 业务数据集合
|
||||
lockCollection: 'transaction_locks', // 锁集合
|
||||
});
|
||||
|
||||
// 创建索引(首次运行时执行)
|
||||
await storage.ensureIndexes();
|
||||
|
||||
const manager = new TransactionManager({ storage });
|
||||
```
|
||||
|
||||
### 特点
|
||||
|
||||
- ✅ 持久化存储
|
||||
- ✅ 支持复杂查询
|
||||
- ✅ 事务日志可追溯
|
||||
- ✅ 适合需要审计的场景
|
||||
- ❌ 相比 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> {
|
||||
// 保存事务日志
|
||||
}
|
||||
|
||||
// ... 实现其他方法
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,40 @@
|
||||
# @esengine/network
|
||||
|
||||
## 2.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#379](https://github.com/esengine/esengine/pull/379) [`fb8bde6`](https://github.com/esengine/esengine/commit/fb8bde64856ef71ea8e20906496682ccfb27f9b3) Thanks [@esengine](https://github.com/esengine)! - feat(network): 网络模块增强
|
||||
|
||||
### 新增功能
|
||||
- **客户端预测 (NetworkPredictionSystem)**
|
||||
- 本地输入预测和服务器校正
|
||||
- 平滑的校正偏移应用
|
||||
- 可配置移动速度、校正阈值等
|
||||
- **兴趣区域管理 (NetworkAOISystem)**
|
||||
- 基于网格的 AOI 实现
|
||||
- 观察者进入/离开事件
|
||||
- 同步数据过滤
|
||||
- **状态增量压缩 (StateDeltaCompressor)**
|
||||
- 只发送变化的字段
|
||||
- 可配置变化阈值
|
||||
- 定期完整快照
|
||||
- **断线重连**
|
||||
- 自动重连机制
|
||||
- Token 认证
|
||||
- 完整状态恢复
|
||||
|
||||
### 协议增强
|
||||
- 添加输入序列号和时间戳
|
||||
- 添加速度和角速度字段
|
||||
- 添加自定义数据字段
|
||||
- 新增重连协议
|
||||
|
||||
### 文档
|
||||
- 添加客户端预测文档(中英文)
|
||||
- 添加 AOI 文档(中英文)
|
||||
- 添加增量压缩文档(中英文)
|
||||
|
||||
## 2.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/network",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"description": "Network synchronization for multiplayer games",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
|
||||
@@ -1,13 +1,126 @@
|
||||
/**
|
||||
* @zh 网络插件
|
||||
* @en Network Plugin
|
||||
*
|
||||
* @zh 提供基于 @esengine/rpc 的网络同步功能,支持客户端预测和断线重连
|
||||
* @en Provides @esengine/rpc based network synchronization with client prediction and reconnection
|
||||
*/
|
||||
|
||||
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
|
||||
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
|
||||
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||
import {
|
||||
GameNetworkService,
|
||||
type NetworkServiceOptions,
|
||||
NetworkState,
|
||||
} from './services/NetworkService'
|
||||
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
|
||||
import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
import { NetworkInputSystem, type NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||
import {
|
||||
NetworkPredictionSystem,
|
||||
type NetworkPredictionConfig,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
import {
|
||||
NetworkAOISystem,
|
||||
type NetworkAOIConfig,
|
||||
} from './systems/NetworkAOISystem'
|
||||
import type { FullStateData, SyncData } from './protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络插件配置
|
||||
* @en Network plugin configuration
|
||||
*/
|
||||
export interface NetworkPluginConfig {
|
||||
/**
|
||||
* @zh 是否启用客户端预测
|
||||
* @en Whether to enable client prediction
|
||||
*/
|
||||
enablePrediction: boolean
|
||||
|
||||
/**
|
||||
* @zh 是否启用自动重连
|
||||
* @en Whether to enable auto reconnection
|
||||
*/
|
||||
enableAutoReconnect: boolean
|
||||
|
||||
/**
|
||||
* @zh 重连最大尝试次数
|
||||
* @en Maximum reconnection attempts
|
||||
*/
|
||||
maxReconnectAttempts: number
|
||||
|
||||
/**
|
||||
* @zh 重连间隔(毫秒)
|
||||
* @en Reconnection interval in milliseconds
|
||||
*/
|
||||
reconnectInterval: number
|
||||
|
||||
/**
|
||||
* @zh 同步系统配置
|
||||
* @en Sync system configuration
|
||||
*/
|
||||
syncConfig?: Partial<NetworkSyncConfig>
|
||||
|
||||
/**
|
||||
* @zh 输入系统配置
|
||||
* @en Input system configuration
|
||||
*/
|
||||
inputConfig?: Partial<NetworkInputConfig>
|
||||
|
||||
/**
|
||||
* @zh 预测系统配置
|
||||
* @en Prediction system configuration
|
||||
*/
|
||||
predictionConfig?: Partial<NetworkPredictionConfig>
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI 兴趣管理
|
||||
* @en Whether to enable AOI interest management
|
||||
*/
|
||||
enableAOI: boolean
|
||||
|
||||
/**
|
||||
* @zh AOI 系统配置
|
||||
* @en AOI system configuration
|
||||
*/
|
||||
aoiConfig?: Partial<NetworkAOIConfig>
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkPluginConfig = {
|
||||
enablePrediction: true,
|
||||
enableAutoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 2000,
|
||||
enableAOI: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 连接选项
|
||||
* @en Connection options
|
||||
*/
|
||||
export interface ConnectOptions extends NetworkServiceOptions {
|
||||
playerName: string
|
||||
roomId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连状态
|
||||
* @en Reconnection state
|
||||
*/
|
||||
interface ReconnectState {
|
||||
token: string
|
||||
playerId: number
|
||||
roomId: string
|
||||
attempts: number
|
||||
isReconnecting: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkPlugin | 网络插件
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络插件
|
||||
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
* import { Core } from '@esengine/ecs-framework'
|
||||
* import { NetworkPlugin } from '@esengine/network'
|
||||
*
|
||||
* const networkPlugin = new NetworkPlugin()
|
||||
* const networkPlugin = new NetworkPlugin({
|
||||
* enablePrediction: true,
|
||||
* enableAutoReconnect: true
|
||||
* })
|
||||
* await Core.installPlugin(networkPlugin)
|
||||
*
|
||||
* // 连接到服务器
|
||||
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
*/
|
||||
export class NetworkPlugin implements IPlugin {
|
||||
public readonly name = '@esengine/network'
|
||||
public readonly version = '2.0.0'
|
||||
public readonly version = '2.1.0'
|
||||
|
||||
private readonly _config: NetworkPluginConfig
|
||||
private _networkService!: GameNetworkService
|
||||
private _syncSystem!: NetworkSyncSystem
|
||||
private _spawnSystem!: NetworkSpawnSystem
|
||||
private _inputSystem!: NetworkInputSystem
|
||||
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||
private _aoiSystem: NetworkAOISystem | null = null
|
||||
|
||||
private _localPlayerId: number = 0
|
||||
private _reconnectState: ReconnectState | null = null
|
||||
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _lastConnectOptions: ConnectOptions | null = null
|
||||
|
||||
constructor(config?: Partial<NetworkPluginConfig>) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Getters | 属性访问器
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络服务
|
||||
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
|
||||
return this._inputSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 预测系统
|
||||
* @en Prediction system
|
||||
*/
|
||||
get predictionSystem(): NetworkPredictionSystem | null {
|
||||
return this._predictionSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh AOI 系统
|
||||
* @en AOI system
|
||||
*/
|
||||
get aoiSystem(): NetworkAOISystem | null {
|
||||
return this._aoiSystem
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 本地玩家 ID
|
||||
* @en Local player ID
|
||||
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
|
||||
return this._networkService?.isConnected ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否正在重连
|
||||
* @en Is reconnecting
|
||||
*/
|
||||
get isReconnecting(): boolean {
|
||||
return this._reconnectState?.isReconnecting ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用预测
|
||||
* @en Is prediction enabled
|
||||
*/
|
||||
get isPredictionEnabled(): boolean {
|
||||
return this._config.enablePrediction && this._predictionSystem !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI
|
||||
* @en Is AOI enabled
|
||||
*/
|
||||
get isAOIEnabled(): boolean {
|
||||
return this._config.enableAOI && this._aoiSystem !== null
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Plugin Lifecycle | 插件生命周期
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 安装插件
|
||||
* @en Install plugin
|
||||
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
|
||||
* @en Uninstall plugin
|
||||
*/
|
||||
uninstall(): void {
|
||||
this._clearReconnectTimer()
|
||||
this._networkService?.disconnect()
|
||||
}
|
||||
|
||||
private _setupSystems(scene: Scene): void {
|
||||
this._syncSystem = new NetworkSyncSystem()
|
||||
// Create systems
|
||||
this._syncSystem = new NetworkSyncSystem(this._config.syncConfig)
|
||||
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
|
||||
this._inputSystem = new NetworkInputSystem(this._networkService)
|
||||
this._inputSystem = new NetworkInputSystem(this._networkService, this._config.inputConfig)
|
||||
|
||||
// Create prediction system if enabled
|
||||
if (this._config.enablePrediction) {
|
||||
this._predictionSystem = new NetworkPredictionSystem(this._config.predictionConfig)
|
||||
this._inputSystem.setPredictionSystem(this._predictionSystem)
|
||||
scene.addSystem(this._predictionSystem)
|
||||
}
|
||||
|
||||
// Create AOI system if enabled
|
||||
if (this._config.enableAOI) {
|
||||
this._aoiSystem = new NetworkAOISystem(this._config.aoiConfig)
|
||||
scene.addSystem(this._aoiSystem)
|
||||
}
|
||||
|
||||
scene.addSystem(this._syncSystem)
|
||||
scene.addSystem(this._spawnSystem)
|
||||
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
|
||||
|
||||
private _setupMessageHandlers(): void {
|
||||
this._networkService
|
||||
.onSync((data) => {
|
||||
this._syncSystem.handleSync({ entities: data.entities })
|
||||
.onSync((data: SyncData) => {
|
||||
// Use new sync handler with timestamps
|
||||
this._syncSystem.handleSyncData(data)
|
||||
|
||||
// Reconcile prediction if enabled
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.reconcileWithServer(data)
|
||||
}
|
||||
})
|
||||
.onSpawn((data) => {
|
||||
this._spawnSystem.handleSpawn(data)
|
||||
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
|
||||
.onDespawn((data) => {
|
||||
this._spawnSystem.handleDespawn(data)
|
||||
})
|
||||
|
||||
// Handle full state for reconnection
|
||||
this._networkService.on('fullState', (data: FullStateData) => {
|
||||
this._handleFullState(data)
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connection | 连接管理
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 连接到服务器
|
||||
* @en Connect to server
|
||||
*/
|
||||
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
|
||||
public async connect(options: ConnectOptions): Promise<boolean> {
|
||||
this._lastConnectOptions = options
|
||||
|
||||
try {
|
||||
// Setup disconnect handler for auto-reconnect
|
||||
const originalOnDisconnect = options.onDisconnect
|
||||
options.onDisconnect = (reason) => {
|
||||
originalOnDisconnect?.(reason)
|
||||
this._handleDisconnect(reason)
|
||||
}
|
||||
|
||||
await this._networkService.connect(options)
|
||||
|
||||
const result = await this._networkService.call('join', {
|
||||
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
|
||||
this._localPlayerId = result.playerId
|
||||
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
|
||||
|
||||
// Setup prediction for local player
|
||||
if (this._predictionSystem) {
|
||||
// Will be set when local player entity is spawned
|
||||
}
|
||||
|
||||
// Save reconnect state
|
||||
if (this._config.enableAutoReconnect) {
|
||||
this._reconnectState = {
|
||||
token: this._generateReconnectToken(),
|
||||
playerId: result.playerId,
|
||||
roomId: result.roomId,
|
||||
attempts: 0,
|
||||
isReconnecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[NetworkPlugin] Connection failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
|
||||
* @en Disconnect
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this._clearReconnectTimer()
|
||||
this._reconnectState = null
|
||||
|
||||
try {
|
||||
await this._networkService.call('leave', undefined)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this._networkService.disconnect()
|
||||
this._cleanup()
|
||||
}
|
||||
|
||||
private _handleDisconnect(reason?: string): void {
|
||||
console.log('[NetworkPlugin] Disconnected:', reason)
|
||||
|
||||
if (this._config.enableAutoReconnect && this._reconnectState && !this._reconnectState.isReconnecting) {
|
||||
this._attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private _attemptReconnect(): void {
|
||||
if (!this._reconnectState || !this._lastConnectOptions) return
|
||||
|
||||
if (this._reconnectState.attempts >= this._config.maxReconnectAttempts) {
|
||||
console.error('[NetworkPlugin] Max reconnection attempts reached')
|
||||
this._reconnectState = null
|
||||
return
|
||||
}
|
||||
|
||||
this._reconnectState.isReconnecting = true
|
||||
this._reconnectState.attempts++
|
||||
|
||||
console.log(`[NetworkPlugin] Attempting reconnection (${this._reconnectState.attempts}/${this._config.maxReconnectAttempts})`)
|
||||
|
||||
this._reconnectTimer = setTimeout(async () => {
|
||||
try {
|
||||
await this._networkService.connect(this._lastConnectOptions!)
|
||||
|
||||
const result = await this._networkService.call('reconnect', {
|
||||
playerId: this._reconnectState!.playerId,
|
||||
roomId: this._reconnectState!.roomId,
|
||||
token: this._reconnectState!.token,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
console.log('[NetworkPlugin] Reconnection successful')
|
||||
this._reconnectState!.isReconnecting = false
|
||||
this._reconnectState!.attempts = 0
|
||||
|
||||
// Restore state
|
||||
if (result.state) {
|
||||
this._handleFullState(result.state)
|
||||
}
|
||||
} else {
|
||||
console.error('[NetworkPlugin] Reconnection rejected:', result.error)
|
||||
this._attemptReconnect()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[NetworkPlugin] Reconnection failed:', err)
|
||||
if (this._reconnectState) {
|
||||
this._reconnectState.isReconnecting = false
|
||||
}
|
||||
this._attemptReconnect()
|
||||
}
|
||||
}, this._config.reconnectInterval)
|
||||
}
|
||||
|
||||
private _handleFullState(data: FullStateData): void {
|
||||
// Clear existing entities
|
||||
this._syncSystem.clearSnapshots()
|
||||
|
||||
// Spawn all entities from full state
|
||||
for (const entityData of data.entities) {
|
||||
this._spawnSystem.handleSpawn(entityData)
|
||||
|
||||
// Apply initial state if available
|
||||
if (entityData.state) {
|
||||
this._syncSystem.handleSyncData({
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
entities: [entityData.state],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _clearReconnectTimer(): void {
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer)
|
||||
this._reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private _generateReconnectToken(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
||||
}
|
||||
|
||||
private _cleanup(): void {
|
||||
this._localPlayerId = 0
|
||||
this._syncSystem?.clearSnapshots()
|
||||
this._predictionSystem?.reset()
|
||||
this._inputSystem?.reset()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Game API | 游戏接口
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 注册预制体工厂
|
||||
* @en Register prefab factory
|
||||
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
|
||||
public sendActionInput(action: string): void {
|
||||
this._inputSystem?.addActionInput(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置本地玩家网络 ID(用于预测)
|
||||
* @en Set local player network ID (for prediction)
|
||||
*/
|
||||
public setLocalPlayerNetId(netId: number): void {
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setLocalPlayerNetId(netId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 启用/禁用预测
|
||||
* @en Enable/disable prediction
|
||||
*/
|
||||
public setPredictionEnabled(enabled: boolean): void {
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// AOI API | AOI 接口
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加 AOI 观察者(玩家)
|
||||
* @en Add AOI observer (player)
|
||||
*/
|
||||
public addAOIObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||
this._aoiSystem?.addObserver(netId, x, y, viewRange)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除 AOI 观察者
|
||||
* @en Remove AOI observer
|
||||
*/
|
||||
public removeAOIObserver(netId: number): void {
|
||||
this._aoiSystem?.removeObserver(netId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新 AOI 观察者位置
|
||||
* @en Update AOI observer position
|
||||
*/
|
||||
public updateAOIObserverPosition(netId: number, x: number, y: number): void {
|
||||
this._aoiSystem?.updateObserverPosition(netId, x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取观察者可见的实体
|
||||
* @en Get entities visible to observer
|
||||
*/
|
||||
public getVisibleEntities(observerNetId: number): number[] {
|
||||
return this._aoiSystem?.getVisibleEntities(observerNetId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否可见
|
||||
* @en Check if visible
|
||||
*/
|
||||
public canSee(observerNetId: number, targetNetId: number): boolean {
|
||||
return this._aoiSystem?.canSee(observerNetId, targetNetId) ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 启用/禁用 AOI
|
||||
* @en Enable/disable AOI
|
||||
*/
|
||||
public setAOIEnabled(enabled: boolean): void {
|
||||
if (this._aoiSystem) {
|
||||
this._aoiSystem.enabled = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ export {
|
||||
type SyncData,
|
||||
type SpawnData,
|
||||
type DespawnData,
|
||||
type FullStateData,
|
||||
type JoinRequest,
|
||||
type JoinResponse,
|
||||
type ReconnectRequest,
|
||||
type ReconnectResponse,
|
||||
} from './protocol'
|
||||
|
||||
// ============================================================================
|
||||
@@ -48,6 +51,8 @@ export {
|
||||
NetworkSyncSystemToken,
|
||||
NetworkSpawnSystemToken,
|
||||
NetworkInputSystemToken,
|
||||
NetworkPredictionSystemToken,
|
||||
NetworkAOISystemToken,
|
||||
} from './tokens'
|
||||
|
||||
// ============================================================================
|
||||
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
|
||||
// ============================================================================
|
||||
|
||||
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||
export type { SyncMessage } from './systems/NetworkSyncSystem'
|
||||
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
|
||||
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
|
||||
export { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
export { NetworkInputSystem, createNetworkInputSystem } from './systems/NetworkInputSystem'
|
||||
export type { NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||
export {
|
||||
NetworkPredictionSystem,
|
||||
createNetworkPredictionSystem,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
export type {
|
||||
NetworkPredictionConfig,
|
||||
MovementInput,
|
||||
PredictedTransform,
|
||||
} from './systems/NetworkPredictionSystem'
|
||||
export {
|
||||
NetworkAOISystem,
|
||||
createNetworkAOISystem,
|
||||
} from './systems/NetworkAOISystem'
|
||||
export type {
|
||||
NetworkAOIConfig,
|
||||
NetworkAOIEvent,
|
||||
NetworkAOIEventType,
|
||||
NetworkAOIEventListener,
|
||||
} from './systems/NetworkAOISystem'
|
||||
|
||||
// ============================================================================
|
||||
// State Sync | 状态同步
|
||||
@@ -105,6 +130,9 @@ export type {
|
||||
IPredictedState,
|
||||
IPredictor,
|
||||
ClientPredictionConfig,
|
||||
EntityDeltaState,
|
||||
DeltaSyncData,
|
||||
DeltaCompressionConfig,
|
||||
} from './sync'
|
||||
|
||||
export {
|
||||
@@ -119,6 +147,9 @@ export {
|
||||
createHermiteTransformInterpolator,
|
||||
ClientPrediction,
|
||||
createClientPrediction,
|
||||
DeltaFlags,
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor,
|
||||
} from './sync'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
|
||||
* @en Player input
|
||||
*/
|
||||
export interface PlayerInput {
|
||||
/**
|
||||
* @zh 输入序列号(用于客户端预测)
|
||||
* @en Input sequence number (for client prediction)
|
||||
*/
|
||||
seq: number
|
||||
|
||||
/**
|
||||
* @zh 帧序号
|
||||
* @en Frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 客户端时间戳
|
||||
* @en Client timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 移动方向
|
||||
* @en Move direction
|
||||
@@ -41,9 +53,41 @@ export interface PlayerInput {
|
||||
* @en Entity sync state
|
||||
*/
|
||||
export interface EntitySyncState {
|
||||
/**
|
||||
* @zh 网络实体 ID
|
||||
* @en Network entity ID
|
||||
*/
|
||||
netId: number
|
||||
|
||||
/**
|
||||
* @zh 位置
|
||||
* @en Position
|
||||
*/
|
||||
pos?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 旋转角度
|
||||
* @en Rotation angle
|
||||
*/
|
||||
rot?: number
|
||||
|
||||
/**
|
||||
* @zh 速度(用于外推)
|
||||
* @en Velocity (for extrapolation)
|
||||
*/
|
||||
vel?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 角速度
|
||||
* @en Angular velocity
|
||||
*/
|
||||
angVel?: number
|
||||
|
||||
/**
|
||||
* @zh 自定义数据
|
||||
* @en Custom data
|
||||
*/
|
||||
custom?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +101,18 @@ export interface SyncData {
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 服务器时间戳(用于插值)
|
||||
* @en Server timestamp (for interpolation)
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 已确认的输入序列号(用于客户端预测校正)
|
||||
* @en Acknowledged input sequence (for client prediction reconciliation)
|
||||
*/
|
||||
ackSeq?: number
|
||||
|
||||
/**
|
||||
* @zh 实体状态列表
|
||||
* @en Entity state list
|
||||
@@ -84,6 +140,30 @@ export interface DespawnData {
|
||||
netId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 完整状态快照(用于重连)
|
||||
* @en Full state snapshot (for reconnection)
|
||||
*/
|
||||
export interface FullStateData {
|
||||
/**
|
||||
* @zh 服务器帧号
|
||||
* @en Server frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 服务器时间戳
|
||||
* @en Server timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 所有实体状态
|
||||
* @en All entity states
|
||||
*/
|
||||
entities: Array<SpawnData & { state?: EntitySyncState }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Types | API 类型
|
||||
// ============================================================================
|
||||
@@ -106,6 +186,54 @@ export interface JoinResponse {
|
||||
roomId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连请求
|
||||
* @en Reconnect request
|
||||
*/
|
||||
export interface ReconnectRequest {
|
||||
/**
|
||||
* @zh 之前的玩家 ID
|
||||
* @en Previous player ID
|
||||
*/
|
||||
playerId: number
|
||||
|
||||
/**
|
||||
* @zh 房间 ID
|
||||
* @en Room ID
|
||||
*/
|
||||
roomId: string
|
||||
|
||||
/**
|
||||
* @zh 重连令牌
|
||||
* @en Reconnection token
|
||||
*/
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重连响应
|
||||
* @en Reconnect response
|
||||
*/
|
||||
export interface ReconnectResponse {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether successful
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* @zh 完整状态(成功时)
|
||||
* @en Full state (when successful)
|
||||
*/
|
||||
state?: FullStateData
|
||||
|
||||
/**
|
||||
* @zh 错误信息(失败时)
|
||||
* @en Error message (when failed)
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Definition | 协议定义
|
||||
// ============================================================================
|
||||
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
|
||||
* @en Leave room
|
||||
*/
|
||||
leave: rpc.api<void, void>(),
|
||||
|
||||
/**
|
||||
* @zh 重连
|
||||
* @en Reconnect
|
||||
*/
|
||||
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
|
||||
},
|
||||
msg: {
|
||||
/**
|
||||
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
|
||||
* @en Entity despawn
|
||||
*/
|
||||
despawn: rpc.msg<DespawnData>(),
|
||||
|
||||
/**
|
||||
* @zh 完整状态快照
|
||||
* @en Full state snapshot
|
||||
*/
|
||||
fullState: rpc.msg<FullStateData>(),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @zh 状态增量压缩
|
||||
* @en State Delta Compression
|
||||
*
|
||||
* @zh 通过只发送变化的字段来减少网络带宽
|
||||
* @en Reduces network bandwidth by only sending changed fields
|
||||
*/
|
||||
|
||||
import type { EntitySyncState, SyncData } from '../protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 增量类型标志
|
||||
* @en Delta type flags
|
||||
*/
|
||||
export const DeltaFlags = {
|
||||
NONE: 0,
|
||||
POSITION: 1 << 0,
|
||||
ROTATION: 1 << 1,
|
||||
VELOCITY: 1 << 2,
|
||||
ANGULAR_VELOCITY: 1 << 3,
|
||||
CUSTOM: 1 << 4,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* @zh 增量状态(只包含变化的字段)
|
||||
* @en Delta state (only contains changed fields)
|
||||
*/
|
||||
export interface EntityDeltaState {
|
||||
/**
|
||||
* @zh 网络标识
|
||||
* @en Network identity
|
||||
*/
|
||||
netId: number
|
||||
|
||||
/**
|
||||
* @zh 变化标志
|
||||
* @en Change flags
|
||||
*/
|
||||
flags: number
|
||||
|
||||
/**
|
||||
* @zh 位置(如果变化)
|
||||
* @en Position (if changed)
|
||||
*/
|
||||
pos?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 旋转(如果变化)
|
||||
* @en Rotation (if changed)
|
||||
*/
|
||||
rot?: number
|
||||
|
||||
/**
|
||||
* @zh 速度(如果变化)
|
||||
* @en Velocity (if changed)
|
||||
*/
|
||||
vel?: { x: number; y: number }
|
||||
|
||||
/**
|
||||
* @zh 角速度(如果变化)
|
||||
* @en Angular velocity (if changed)
|
||||
*/
|
||||
angVel?: number
|
||||
|
||||
/**
|
||||
* @zh 自定义数据(如果变化)
|
||||
* @en Custom data (if changed)
|
||||
*/
|
||||
custom?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 增量同步数据
|
||||
* @en Delta sync data
|
||||
*/
|
||||
export interface DeltaSyncData {
|
||||
/**
|
||||
* @zh 帧号
|
||||
* @en Frame number
|
||||
*/
|
||||
frame: number
|
||||
|
||||
/**
|
||||
* @zh 时间戳
|
||||
* @en Timestamp
|
||||
*/
|
||||
timestamp: number
|
||||
|
||||
/**
|
||||
* @zh 已确认的输入序列号
|
||||
* @en Acknowledged input sequence
|
||||
*/
|
||||
ackSeq?: number
|
||||
|
||||
/**
|
||||
* @zh 增量实体状态
|
||||
* @en Delta entity states
|
||||
*/
|
||||
entities: EntityDeltaState[]
|
||||
|
||||
/**
|
||||
* @zh 是否为完整快照
|
||||
* @en Whether this is a full snapshot
|
||||
*/
|
||||
isFullSnapshot?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 增量压缩配置
|
||||
* @en Delta compression configuration
|
||||
*/
|
||||
export interface DeltaCompressionConfig {
|
||||
/**
|
||||
* @zh 位置变化阈值
|
||||
* @en Position change threshold
|
||||
*/
|
||||
positionThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 旋转变化阈值(弧度)
|
||||
* @en Rotation change threshold (radians)
|
||||
*/
|
||||
rotationThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 速度变化阈值
|
||||
* @en Velocity change threshold
|
||||
*/
|
||||
velocityThreshold: number
|
||||
|
||||
/**
|
||||
* @zh 强制完整快照间隔(帧数)
|
||||
* @en Forced full snapshot interval (frames)
|
||||
*/
|
||||
fullSnapshotInterval: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DeltaCompressionConfig = {
|
||||
positionThreshold: 0.01,
|
||||
rotationThreshold: 0.001,
|
||||
velocityThreshold: 0.1,
|
||||
fullSnapshotInterval: 60,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// StateDeltaCompressor | 状态增量压缩器
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 状态增量压缩器
|
||||
* @en State delta compressor
|
||||
*
|
||||
* @zh 追踪实体状态变化,生成增量更新
|
||||
* @en Tracks entity state changes and generates delta updates
|
||||
*/
|
||||
export class StateDeltaCompressor {
|
||||
private readonly _config: DeltaCompressionConfig
|
||||
private readonly _lastStates: Map<number, EntitySyncState> = new Map()
|
||||
private _frameCounter: number = 0
|
||||
|
||||
constructor(config?: Partial<DeltaCompressionConfig>) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<DeltaCompressionConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 压缩同步数据为增量格式
|
||||
* @en Compress sync data to delta format
|
||||
*/
|
||||
compress(data: SyncData): DeltaSyncData {
|
||||
this._frameCounter++
|
||||
|
||||
const isFullSnapshot = this._frameCounter % this._config.fullSnapshotInterval === 0
|
||||
const deltaEntities: EntityDeltaState[] = []
|
||||
|
||||
for (const entity of data.entities) {
|
||||
const lastState = this._lastStates.get(entity.netId)
|
||||
|
||||
if (isFullSnapshot || !lastState) {
|
||||
// Send full state
|
||||
deltaEntities.push(this._createFullDelta(entity))
|
||||
} else {
|
||||
// Calculate delta
|
||||
const delta = this._calculateDelta(lastState, entity)
|
||||
if (delta) {
|
||||
deltaEntities.push(delta)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last state
|
||||
this._lastStates.set(entity.netId, { ...entity })
|
||||
}
|
||||
|
||||
return {
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
ackSeq: data.ackSeq,
|
||||
entities: deltaEntities,
|
||||
isFullSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解压增量数据为完整同步数据
|
||||
* @en Decompress delta data to full sync data
|
||||
*/
|
||||
decompress(data: DeltaSyncData): SyncData {
|
||||
const entities: EntitySyncState[] = []
|
||||
|
||||
for (const delta of data.entities) {
|
||||
const lastState = this._lastStates.get(delta.netId)
|
||||
const fullState = this._applyDelta(lastState, delta)
|
||||
entities.push(fullState)
|
||||
|
||||
// Update last state
|
||||
this._lastStates.set(delta.netId, fullState)
|
||||
}
|
||||
|
||||
return {
|
||||
frame: data.frame,
|
||||
timestamp: data.timestamp,
|
||||
ackSeq: data.ackSeq,
|
||||
entities,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除实体状态
|
||||
* @en Remove entity state
|
||||
*/
|
||||
removeEntity(netId: number): void {
|
||||
this._lastStates.delete(netId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有状态
|
||||
* @en Clear all states
|
||||
*/
|
||||
clear(): void {
|
||||
this._lastStates.clear()
|
||||
this._frameCounter = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 强制下一次发送完整快照
|
||||
* @en Force next send to be a full snapshot
|
||||
*/
|
||||
forceFullSnapshot(): void {
|
||||
this._frameCounter = this._config.fullSnapshotInterval - 1
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private _createFullDelta(entity: EntitySyncState): EntityDeltaState {
|
||||
let flags = 0
|
||||
|
||||
if (entity.pos) flags |= DeltaFlags.POSITION
|
||||
if (entity.rot !== undefined) flags |= DeltaFlags.ROTATION
|
||||
if (entity.vel) flags |= DeltaFlags.VELOCITY
|
||||
if (entity.angVel !== undefined) flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||
if (entity.custom) flags |= DeltaFlags.CUSTOM
|
||||
|
||||
return {
|
||||
netId: entity.netId,
|
||||
flags,
|
||||
pos: entity.pos,
|
||||
rot: entity.rot,
|
||||
vel: entity.vel,
|
||||
angVel: entity.angVel,
|
||||
custom: entity.custom,
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateDelta(
|
||||
lastState: EntitySyncState,
|
||||
currentState: EntitySyncState
|
||||
): EntityDeltaState | null {
|
||||
let flags = 0
|
||||
const delta: EntityDeltaState = {
|
||||
netId: currentState.netId,
|
||||
flags: 0,
|
||||
}
|
||||
|
||||
// Check position change
|
||||
if (currentState.pos) {
|
||||
const posChanged = !lastState.pos ||
|
||||
Math.abs(currentState.pos.x - lastState.pos.x) > this._config.positionThreshold ||
|
||||
Math.abs(currentState.pos.y - lastState.pos.y) > this._config.positionThreshold
|
||||
|
||||
if (posChanged) {
|
||||
flags |= DeltaFlags.POSITION
|
||||
delta.pos = currentState.pos
|
||||
}
|
||||
}
|
||||
|
||||
// Check rotation change
|
||||
if (currentState.rot !== undefined) {
|
||||
const rotChanged = lastState.rot === undefined ||
|
||||
Math.abs(currentState.rot - lastState.rot) > this._config.rotationThreshold
|
||||
|
||||
if (rotChanged) {
|
||||
flags |= DeltaFlags.ROTATION
|
||||
delta.rot = currentState.rot
|
||||
}
|
||||
}
|
||||
|
||||
// Check velocity change
|
||||
if (currentState.vel) {
|
||||
const velChanged = !lastState.vel ||
|
||||
Math.abs(currentState.vel.x - lastState.vel.x) > this._config.velocityThreshold ||
|
||||
Math.abs(currentState.vel.y - lastState.vel.y) > this._config.velocityThreshold
|
||||
|
||||
if (velChanged) {
|
||||
flags |= DeltaFlags.VELOCITY
|
||||
delta.vel = currentState.vel
|
||||
}
|
||||
}
|
||||
|
||||
// Check angular velocity change
|
||||
if (currentState.angVel !== undefined) {
|
||||
const angVelChanged = lastState.angVel === undefined ||
|
||||
Math.abs(currentState.angVel - lastState.angVel) > this._config.velocityThreshold
|
||||
|
||||
if (angVelChanged) {
|
||||
flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||
delta.angVel = currentState.angVel
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom data change (simple reference comparison)
|
||||
if (currentState.custom) {
|
||||
const customChanged = !this._customDataEqual(lastState.custom, currentState.custom)
|
||||
|
||||
if (customChanged) {
|
||||
flags |= DeltaFlags.CUSTOM
|
||||
delta.custom = currentState.custom
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if no changes
|
||||
if (flags === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
delta.flags = flags
|
||||
return delta
|
||||
}
|
||||
|
||||
private _applyDelta(
|
||||
lastState: EntitySyncState | undefined,
|
||||
delta: EntityDeltaState
|
||||
): EntitySyncState {
|
||||
const state: EntitySyncState = {
|
||||
netId: delta.netId,
|
||||
}
|
||||
|
||||
// Apply position
|
||||
if (delta.flags & DeltaFlags.POSITION) {
|
||||
state.pos = delta.pos
|
||||
} else if (lastState?.pos) {
|
||||
state.pos = lastState.pos
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
if (delta.flags & DeltaFlags.ROTATION) {
|
||||
state.rot = delta.rot
|
||||
} else if (lastState?.rot !== undefined) {
|
||||
state.rot = lastState.rot
|
||||
}
|
||||
|
||||
// Apply velocity
|
||||
if (delta.flags & DeltaFlags.VELOCITY) {
|
||||
state.vel = delta.vel
|
||||
} else if (lastState?.vel) {
|
||||
state.vel = lastState.vel
|
||||
}
|
||||
|
||||
// Apply angular velocity
|
||||
if (delta.flags & DeltaFlags.ANGULAR_VELOCITY) {
|
||||
state.angVel = delta.angVel
|
||||
} else if (lastState?.angVel !== undefined) {
|
||||
state.angVel = lastState.angVel
|
||||
}
|
||||
|
||||
// Apply custom data
|
||||
if (delta.flags & DeltaFlags.CUSTOM) {
|
||||
state.custom = delta.custom
|
||||
} else if (lastState?.custom) {
|
||||
state.custom = lastState.custom
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
private _customDataEqual(
|
||||
a: Record<string, unknown> | undefined,
|
||||
b: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
|
||||
if (keysA.length !== keysB.length) return false
|
||||
|
||||
for (const key of keysA) {
|
||||
if (a[key] !== b[key]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建状态增量压缩器
|
||||
* @en Create state delta compressor
|
||||
*/
|
||||
export function createStateDeltaCompressor(
|
||||
config?: Partial<DeltaCompressionConfig>
|
||||
): StateDeltaCompressor {
|
||||
return new StateDeltaCompressor(config)
|
||||
}
|
||||
@@ -46,3 +46,19 @@ export type {
|
||||
} from './ClientPrediction';
|
||||
|
||||
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
|
||||
|
||||
// =============================================================================
|
||||
// 状态增量压缩 | State Delta Compression
|
||||
// =============================================================================
|
||||
|
||||
export type {
|
||||
EntityDeltaState,
|
||||
DeltaSyncData,
|
||||
DeltaCompressionConfig
|
||||
} from './StateDelta';
|
||||
|
||||
export {
|
||||
DeltaFlags,
|
||||
StateDeltaCompressor,
|
||||
createStateDeltaCompressor
|
||||
} from './StateDelta';
|
||||
|
||||
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* @zh 网络 AOI 系统
|
||||
* @en Network AOI System
|
||||
*
|
||||
* @zh 集成 AOI 兴趣区域管理,过滤网络同步数据
|
||||
* @en Integrates AOI interest management to filter network sync data
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||
import { NetworkTransform } from '../components/NetworkTransform'
|
||||
import type { EntitySyncState } from '../protocol'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh AOI 事件类型
|
||||
* @en AOI event type
|
||||
*/
|
||||
export type NetworkAOIEventType = 'enter' | 'exit'
|
||||
|
||||
/**
|
||||
* @zh AOI 事件
|
||||
* @en AOI event
|
||||
*/
|
||||
export interface NetworkAOIEvent {
|
||||
/**
|
||||
* @zh 事件类型
|
||||
* @en Event type
|
||||
*/
|
||||
type: NetworkAOIEventType
|
||||
|
||||
/**
|
||||
* @zh 观察者网络 ID(玩家)
|
||||
* @en Observer network ID (player)
|
||||
*/
|
||||
observerNetId: number
|
||||
|
||||
/**
|
||||
* @zh 目标网络 ID(进入/离开视野的实体)
|
||||
* @en Target network ID (entity entering/exiting view)
|
||||
*/
|
||||
targetNetId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh AOI 事件监听器
|
||||
* @en AOI event listener
|
||||
*/
|
||||
export type NetworkAOIEventListener = (event: NetworkAOIEvent) => void
|
||||
|
||||
/**
|
||||
* @zh 网络 AOI 配置
|
||||
* @en Network AOI configuration
|
||||
*/
|
||||
export interface NetworkAOIConfig {
|
||||
/**
|
||||
* @zh 网格单元格大小
|
||||
* @en Grid cell size
|
||||
*/
|
||||
cellSize: number
|
||||
|
||||
/**
|
||||
* @zh 默认视野范围
|
||||
* @en Default view range
|
||||
*/
|
||||
defaultViewRange: number
|
||||
|
||||
/**
|
||||
* @zh 是否启用 AOI 过滤
|
||||
* @en Whether to enable AOI filtering
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkAOIConfig = {
|
||||
cellSize: 100,
|
||||
defaultViewRange: 500,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 观察者数据
|
||||
* @en Observer data
|
||||
*/
|
||||
interface ObserverData {
|
||||
netId: number
|
||||
position: { x: number; y: number }
|
||||
viewRange: number
|
||||
viewRangeSq: number
|
||||
cellKey: string
|
||||
visibleEntities: Set<number>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkAOISystem | 网络 AOI 系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络 AOI 系统
|
||||
* @en Network AOI system
|
||||
*
|
||||
* @zh 管理网络实体的兴趣区域,过滤同步数据
|
||||
* @en Manages network entities' areas of interest and filters sync data
|
||||
*/
|
||||
export class NetworkAOISystem extends EntitySystem {
|
||||
private readonly _config: NetworkAOIConfig
|
||||
private readonly _observers: Map<number, ObserverData> = new Map()
|
||||
private readonly _cells: Map<string, Set<number>> = new Map()
|
||||
private readonly _listeners: Set<NetworkAOIEventListener> = new Set()
|
||||
private readonly _entityNetIdMap: Map<Entity, number> = new Map()
|
||||
private readonly _netIdEntityMap: Map<number, Entity> = new Map()
|
||||
|
||||
constructor(config?: Partial<NetworkAOIConfig>) {
|
||||
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkAOIConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用
|
||||
* @en Is enabled
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return this._config.enabled
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
this._config.enabled = value
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 观察者数量
|
||||
* @en Observer count
|
||||
*/
|
||||
get observerCount(): number {
|
||||
return this._observers.size
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 观察者管理 | Observer Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加观察者(通常是玩家实体)
|
||||
* @en Add observer (usually player entity)
|
||||
*/
|
||||
addObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||
if (this._observers.has(netId)) {
|
||||
this.updateObserverPosition(netId, x, y)
|
||||
return
|
||||
}
|
||||
|
||||
const range = viewRange ?? this._config.defaultViewRange
|
||||
const cellKey = this._getCellKey(x, y)
|
||||
const data: ObserverData = {
|
||||
netId,
|
||||
position: { x, y },
|
||||
viewRange: range,
|
||||
viewRangeSq: range * range,
|
||||
cellKey,
|
||||
visibleEntities: new Set(),
|
||||
}
|
||||
|
||||
this._observers.set(netId, data)
|
||||
this._addToCell(cellKey, netId)
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除观察者
|
||||
* @en Remove observer
|
||||
*/
|
||||
removeObserver(netId: number): boolean {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return false
|
||||
|
||||
// Emit exit events for all visible entities
|
||||
for (const visibleNetId of data.visibleEntities) {
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: netId,
|
||||
targetNetId: visibleNetId,
|
||||
})
|
||||
}
|
||||
|
||||
this._removeFromCell(data.cellKey, netId)
|
||||
this._observers.delete(netId)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新观察者位置
|
||||
* @en Update observer position
|
||||
*/
|
||||
updateObserverPosition(netId: number, x: number, y: number): void {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return
|
||||
|
||||
const newCellKey = this._getCellKey(x, y)
|
||||
if (newCellKey !== data.cellKey) {
|
||||
this._removeFromCell(data.cellKey, netId)
|
||||
data.cellKey = newCellKey
|
||||
this._addToCell(newCellKey, netId)
|
||||
}
|
||||
|
||||
data.position.x = x
|
||||
data.position.y = y
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新观察者视野范围
|
||||
* @en Update observer view range
|
||||
*/
|
||||
updateObserverViewRange(netId: number, viewRange: number): void {
|
||||
const data = this._observers.get(netId)
|
||||
if (!data) return
|
||||
|
||||
data.viewRange = viewRange
|
||||
data.viewRangeSq = viewRange * viewRange
|
||||
this._updateVisibility(data)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 实体管理 | Entity Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 注册网络实体
|
||||
* @en Register network entity
|
||||
*/
|
||||
registerEntity(entity: Entity, netId: number): void {
|
||||
this._entityNetIdMap.set(entity, netId)
|
||||
this._netIdEntityMap.set(netId, entity)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注销网络实体
|
||||
* @en Unregister network entity
|
||||
*/
|
||||
unregisterEntity(entity: Entity): void {
|
||||
const netId = this._entityNetIdMap.get(entity)
|
||||
if (netId !== undefined) {
|
||||
// Remove from all observers' visible sets
|
||||
for (const [, data] of this._observers) {
|
||||
if (data.visibleEntities.has(netId)) {
|
||||
data.visibleEntities.delete(netId)
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
this._netIdEntityMap.delete(netId)
|
||||
}
|
||||
this._entityNetIdMap.delete(entity)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 查询接口 | Query Interface
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 获取观察者能看到的实体网络 ID 列表
|
||||
* @en Get list of entity network IDs visible to observer
|
||||
*/
|
||||
getVisibleEntities(observerNetId: number): number[] {
|
||||
const data = this._observers.get(observerNetId)
|
||||
return data ? Array.from(data.visibleEntities) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取能看到指定实体的观察者网络 ID 列表
|
||||
* @en Get list of observer network IDs that can see the entity
|
||||
*/
|
||||
getObserversOf(entityNetId: number): number[] {
|
||||
const observers: number[] = []
|
||||
for (const [, data] of this._observers) {
|
||||
if (data.visibleEntities.has(entityNetId)) {
|
||||
observers.push(data.netId)
|
||||
}
|
||||
}
|
||||
return observers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查观察者是否能看到目标
|
||||
* @en Check if observer can see target
|
||||
*/
|
||||
canSee(observerNetId: number, targetNetId: number): boolean {
|
||||
const data = this._observers.get(observerNetId)
|
||||
return data?.visibleEntities.has(targetNetId) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 过滤同步数据,只保留观察者能看到的实体
|
||||
* @en Filter sync data to only include entities visible to observer
|
||||
*/
|
||||
filterSyncData(observerNetId: number, entities: EntitySyncState[]): EntitySyncState[] {
|
||||
if (!this._config.enabled) {
|
||||
return entities
|
||||
}
|
||||
|
||||
const data = this._observers.get(observerNetId)
|
||||
if (!data) {
|
||||
return entities
|
||||
}
|
||||
|
||||
return entities.filter(entity => {
|
||||
// Always include the observer's own entity
|
||||
if (entity.netId === observerNetId) return true
|
||||
// Include entities in view
|
||||
return data.visibleEntities.has(entity.netId)
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 事件系统 | Event System
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加事件监听器
|
||||
* @en Add event listener
|
||||
*/
|
||||
addListener(listener: NetworkAOIEventListener): void {
|
||||
this._listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除事件监听器
|
||||
* @en Remove event listener
|
||||
*/
|
||||
removeListener(listener: NetworkAOIEventListener): void {
|
||||
this._listeners.delete(listener)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 系统生命周期 | System Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
if (!this._config.enabled) return
|
||||
|
||||
// Update entity positions for AOI calculations
|
||||
for (const entity of entities) {
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
|
||||
// Register entity if not already registered
|
||||
if (!this._entityNetIdMap.has(entity)) {
|
||||
this.registerEntity(entity, identity.netId)
|
||||
}
|
||||
|
||||
// If this entity is an observer (has authority), update its position
|
||||
if (identity.bHasAuthority && this._observers.has(identity.netId)) {
|
||||
this.updateObserverPosition(
|
||||
identity.netId,
|
||||
transform.currentX,
|
||||
transform.currentY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all observers' visibility based on entity positions
|
||||
this._updateAllObserversVisibility(entities)
|
||||
}
|
||||
|
||||
private _updateAllObserversVisibility(entities: readonly Entity[]): void {
|
||||
for (const [, data] of this._observers) {
|
||||
const newVisible = new Set<number>()
|
||||
|
||||
// Check all entities
|
||||
for (const entity of entities) {
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
|
||||
// Skip self
|
||||
if (identity.netId === data.netId) continue
|
||||
|
||||
// Check distance
|
||||
const dx = transform.currentX - data.position.x
|
||||
const dy = transform.currentY - data.position.y
|
||||
const distSq = dx * dx + dy * dy
|
||||
|
||||
if (distSq <= data.viewRangeSq) {
|
||||
newVisible.add(identity.netId)
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities that entered view
|
||||
for (const netId of newVisible) {
|
||||
if (!data.visibleEntities.has(netId)) {
|
||||
this._emitEvent({
|
||||
type: 'enter',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Find entities that exited view
|
||||
for (const netId of data.visibleEntities) {
|
||||
if (!newVisible.has(netId)) {
|
||||
this._emitEvent({
|
||||
type: 'exit',
|
||||
observerNetId: data.netId,
|
||||
targetNetId: netId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data.visibleEntities = newVisible
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除所有数据
|
||||
* @en Clear all data
|
||||
*/
|
||||
clear(): void {
|
||||
this._observers.clear()
|
||||
this._cells.clear()
|
||||
this._entityNetIdMap.clear()
|
||||
this._netIdEntityMap.clear()
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this.clear()
|
||||
this._listeners.clear()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private Methods
|
||||
// =========================================================================
|
||||
|
||||
private _getCellKey(x: number, y: number): string {
|
||||
const cellX = Math.floor(x / this._config.cellSize)
|
||||
const cellY = Math.floor(y / this._config.cellSize)
|
||||
return `${cellX},${cellY}`
|
||||
}
|
||||
|
||||
private _addToCell(cellKey: string, netId: number): void {
|
||||
let cell = this._cells.get(cellKey)
|
||||
if (!cell) {
|
||||
cell = new Set()
|
||||
this._cells.set(cellKey, cell)
|
||||
}
|
||||
cell.add(netId)
|
||||
}
|
||||
|
||||
private _removeFromCell(cellKey: string, netId: number): void {
|
||||
const cell = this._cells.get(cellKey)
|
||||
if (cell) {
|
||||
cell.delete(netId)
|
||||
if (cell.size === 0) {
|
||||
this._cells.delete(cellKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateVisibility(data: ObserverData): void {
|
||||
// This is called when an observer moves
|
||||
// The full visibility update happens in process() with all entities
|
||||
}
|
||||
|
||||
private _emitEvent(event: NetworkAOIEvent): void {
|
||||
for (const listener of this._listeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('[NetworkAOISystem] Listener error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建网络 AOI 系统
|
||||
* @en Create network AOI system
|
||||
*/
|
||||
export function createNetworkAOISystem(
|
||||
config?: Partial<NetworkAOIConfig>
|
||||
): NetworkAOISystem {
|
||||
return new NetworkAOISystem(config)
|
||||
}
|
||||
@@ -1,11 +1,63 @@
|
||||
/**
|
||||
* @zh 网络输入系统
|
||||
* @en Network Input System
|
||||
*
|
||||
* @zh 收集本地玩家输入并发送到服务器,支持与预测系统集成
|
||||
* @en Collects local player input and sends to server, supports integration with prediction system
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
|
||||
import type { PlayerInput } from '../protocol'
|
||||
import type { NetworkService } from '../services/NetworkService'
|
||||
import type { NetworkPredictionSystem } from './NetworkPredictionSystem'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 输入配置
|
||||
* @en Input configuration
|
||||
*/
|
||||
export interface NetworkInputConfig {
|
||||
/**
|
||||
* @zh 发送输入的最小间隔(毫秒)
|
||||
* @en Minimum interval between input sends (milliseconds)
|
||||
*/
|
||||
sendInterval: number
|
||||
|
||||
/**
|
||||
* @zh 是否合并相同输入
|
||||
* @en Whether to merge identical inputs
|
||||
*/
|
||||
mergeIdenticalInputs: boolean
|
||||
|
||||
/**
|
||||
* @zh 最大输入队列长度
|
||||
* @en Maximum input queue length
|
||||
*/
|
||||
maxQueueLength: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkInputConfig = {
|
||||
sendInterval: 16, // ~60fps
|
||||
mergeIdenticalInputs: true,
|
||||
maxQueueLength: 10,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 待发送输入
|
||||
* @en Pending input
|
||||
*/
|
||||
interface PendingInput {
|
||||
moveDir?: { x: number; y: number }
|
||||
actions?: string[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkInputSystem | 网络输入系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络输入系统
|
||||
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
|
||||
* @en Collects local player input and sends to server
|
||||
*/
|
||||
export class NetworkInputSystem extends EntitySystem {
|
||||
private _networkService: NetworkService
|
||||
private _frame: number = 0
|
||||
private _inputQueue: PlayerInput[] = []
|
||||
private readonly _networkService: NetworkService
|
||||
private readonly _config: NetworkInputConfig
|
||||
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||
|
||||
constructor(networkService: NetworkService) {
|
||||
private _frame: number = 0
|
||||
private _inputSequence: number = 0
|
||||
private _inputQueue: PendingInput[] = []
|
||||
private _lastSendTime: number = 0
|
||||
private _lastMoveDir: { x: number; y: number } = { x: 0, y: 0 }
|
||||
|
||||
constructor(networkService: NetworkService, config?: Partial<NetworkInputConfig>) {
|
||||
super(Matcher.nothing())
|
||||
this._networkService = networkService
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkInputConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前帧号
|
||||
* @en Get current frame number
|
||||
*/
|
||||
get frame(): number {
|
||||
return this._frame
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前输入序列号
|
||||
* @en Get current input sequence
|
||||
*/
|
||||
get inputSequence(): number {
|
||||
return this._inputSequence
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置预测系统引用
|
||||
* @en Set prediction system reference
|
||||
*/
|
||||
setPredictionSystem(system: NetworkPredictionSystem): void {
|
||||
this._predictionSystem = system
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,11 +123,64 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
if (!this._networkService.isConnected) return
|
||||
|
||||
this._frame++
|
||||
const now = Date.now()
|
||||
|
||||
while (this._inputQueue.length > 0) {
|
||||
const input = this._inputQueue.shift()!
|
||||
input.frame = this._frame
|
||||
this._networkService.sendInput(input)
|
||||
// Rate limiting
|
||||
if (now - this._lastSendTime < this._config.sendInterval) return
|
||||
|
||||
// If using prediction system, get input from there
|
||||
if (this._predictionSystem) {
|
||||
const predictedInput = this._predictionSystem.getInputToSend()
|
||||
if (predictedInput) {
|
||||
this._networkService.sendInput(predictedInput)
|
||||
this._lastSendTime = now
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise process queue
|
||||
if (this._inputQueue.length === 0) return
|
||||
|
||||
// Merge inputs if configured
|
||||
let mergedInput: PendingInput
|
||||
if (this._config.mergeIdenticalInputs && this._inputQueue.length > 1) {
|
||||
mergedInput = this._mergeInputs(this._inputQueue)
|
||||
this._inputQueue.length = 0
|
||||
} else {
|
||||
mergedInput = this._inputQueue.shift()!
|
||||
}
|
||||
|
||||
// Build and send input
|
||||
this._inputSequence++
|
||||
const input: PlayerInput = {
|
||||
seq: this._inputSequence,
|
||||
frame: this._frame,
|
||||
timestamp: mergedInput.timestamp,
|
||||
moveDir: mergedInput.moveDir,
|
||||
actions: mergedInput.actions,
|
||||
}
|
||||
|
||||
this._networkService.sendInput(input)
|
||||
this._lastSendTime = now
|
||||
}
|
||||
|
||||
private _mergeInputs(inputs: PendingInput[]): PendingInput {
|
||||
const allActions: string[] = []
|
||||
let lastMoveDir: { x: number; y: number } | undefined
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.moveDir) {
|
||||
lastMoveDir = input.moveDir
|
||||
}
|
||||
if (input.actions) {
|
||||
allActions.push(...input.actions)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
moveDir: lastMoveDir,
|
||||
actions: allActions.length > 0 ? allActions : undefined,
|
||||
timestamp: inputs[inputs.length - 1].timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +189,24 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
* @en Add move input
|
||||
*/
|
||||
public addMoveInput(x: number, y: number): void {
|
||||
this._inputQueue.push({
|
||||
frame: 0,
|
||||
moveDir: { x, y },
|
||||
})
|
||||
// Skip if same as last input
|
||||
if (
|
||||
this._config.mergeIdenticalInputs &&
|
||||
this._lastMoveDir.x === x &&
|
||||
this._lastMoveDir.y === y &&
|
||||
this._inputQueue.length > 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this._lastMoveDir = { x, y }
|
||||
|
||||
// Also set input on prediction system
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setInput(x, y)
|
||||
}
|
||||
|
||||
this._addToQueue({ moveDir: { x, y }, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,19 +214,70 @@ export class NetworkInputSystem extends EntitySystem {
|
||||
* @en Add action input
|
||||
*/
|
||||
public addActionInput(action: string): void {
|
||||
// Try to add to last input in queue
|
||||
const lastInput = this._inputQueue[this._inputQueue.length - 1]
|
||||
if (lastInput) {
|
||||
lastInput.actions = lastInput.actions || []
|
||||
lastInput.actions.push(action)
|
||||
} else {
|
||||
this._inputQueue.push({
|
||||
frame: 0,
|
||||
actions: [action],
|
||||
})
|
||||
this._addToQueue({ actions: [action], timestamp: Date.now() })
|
||||
}
|
||||
|
||||
// Also set on prediction system
|
||||
if (this._predictionSystem) {
|
||||
this._predictionSystem.setInput(
|
||||
this._lastMoveDir.x,
|
||||
this._lastMoveDir.y,
|
||||
[action]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _addToQueue(input: PendingInput): void {
|
||||
this._inputQueue.push(input)
|
||||
|
||||
// Limit queue size
|
||||
while (this._inputQueue.length > this._config.maxQueueLength) {
|
||||
this._inputQueue.shift()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清空输入队列
|
||||
* @en Clear input queue
|
||||
*/
|
||||
public clearQueue(): void {
|
||||
this._inputQueue.length = 0
|
||||
this._lastMoveDir = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置状态
|
||||
* @en Reset state
|
||||
*/
|
||||
public reset(): void {
|
||||
this._frame = 0
|
||||
this._inputSequence = 0
|
||||
this.clearQueue()
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._inputQueue.length = 0
|
||||
this._predictionSystem = null
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建网络输入系统
|
||||
* @en Create network input system
|
||||
*/
|
||||
export function createNetworkInputSystem(
|
||||
networkService: NetworkService,
|
||||
config?: Partial<NetworkInputConfig>
|
||||
): NetworkInputSystem {
|
||||
return new NetworkInputSystem(networkService, config)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @zh 网络预测系统
|
||||
* @en Network Prediction System
|
||||
*
|
||||
* @zh 处理本地玩家的客户端预测和服务器校正
|
||||
* @en Handles client-side prediction and server reconciliation for local player
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||
import { NetworkTransform } from '../components/NetworkTransform'
|
||||
import type { SyncData, PlayerInput } from '../protocol'
|
||||
import {
|
||||
ClientPrediction,
|
||||
createClientPrediction,
|
||||
type IPredictor,
|
||||
type ClientPredictionConfig,
|
||||
type ITransformState,
|
||||
} from '../sync'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 移动输入
|
||||
* @en Movement input
|
||||
*/
|
||||
export interface MovementInput {
|
||||
x: number
|
||||
y: number
|
||||
actions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 预测状态(位置 + 旋转)
|
||||
* @en Predicted state (position + rotation)
|
||||
*/
|
||||
export interface PredictedTransform extends ITransformState {
|
||||
velocityX: number
|
||||
velocityY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 预测系统配置
|
||||
* @en Prediction system configuration
|
||||
*/
|
||||
export interface NetworkPredictionConfig extends Partial<ClientPredictionConfig> {
|
||||
/**
|
||||
* @zh 移动速度(单位/秒)
|
||||
* @en Movement speed (units/second)
|
||||
*/
|
||||
moveSpeed: number
|
||||
|
||||
/**
|
||||
* @zh 是否启用预测
|
||||
* @en Whether prediction is enabled
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkPredictionConfig = {
|
||||
moveSpeed: 200,
|
||||
enabled: true,
|
||||
maxUnacknowledgedInputs: 60,
|
||||
reconciliationThreshold: 0.5,
|
||||
reconciliationSpeed: 10,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 默认预测器 | Default Predictor
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 简单移动预测器
|
||||
* @en Simple movement predictor
|
||||
*/
|
||||
class SimpleMovementPredictor implements IPredictor<PredictedTransform, MovementInput> {
|
||||
constructor(private readonly _moveSpeed: number) {}
|
||||
|
||||
predict(state: PredictedTransform, input: MovementInput, deltaTime: number): PredictedTransform {
|
||||
const velocityX = input.x * this._moveSpeed
|
||||
const velocityY = input.y * this._moveSpeed
|
||||
|
||||
return {
|
||||
x: state.x + velocityX * deltaTime,
|
||||
y: state.y + velocityY * deltaTime,
|
||||
rotation: state.rotation,
|
||||
velocityX,
|
||||
velocityY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkPredictionSystem | 网络预测系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络预测系统
|
||||
* @en Network prediction system
|
||||
*
|
||||
* @zh 处理本地玩家的输入预测和服务器状态校正
|
||||
* @en Handles local player input prediction and server state reconciliation
|
||||
*/
|
||||
export class NetworkPredictionSystem extends EntitySystem {
|
||||
private readonly _config: NetworkPredictionConfig
|
||||
private readonly _predictor: IPredictor<PredictedTransform, MovementInput>
|
||||
private _prediction: ClientPrediction<PredictedTransform, MovementInput> | null = null
|
||||
private _localPlayerNetId: number = -1
|
||||
private _currentInput: MovementInput = { x: 0, y: 0 }
|
||||
private _inputSequence: number = 0
|
||||
|
||||
constructor(config?: Partial<NetworkPredictionConfig>) {
|
||||
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
this._predictor = new SimpleMovementPredictor(this._config.moveSpeed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkPredictionConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前输入序列号
|
||||
* @en Get current input sequence number
|
||||
*/
|
||||
get inputSequence(): number {
|
||||
return this._inputSequence
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取待确认输入数量
|
||||
* @en Get pending input count
|
||||
*/
|
||||
get pendingInputCount(): number {
|
||||
return this._prediction?.pendingInputCount ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否启用预测
|
||||
* @en Whether prediction is enabled
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return this._config.enabled
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
this._config.enabled = value
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置本地玩家网络 ID
|
||||
* @en Set local player network ID
|
||||
*/
|
||||
setLocalPlayerNetId(netId: number): void {
|
||||
this._localPlayerNetId = netId
|
||||
this._prediction = createClientPrediction<PredictedTransform, MovementInput>(
|
||||
this._predictor,
|
||||
{
|
||||
maxUnacknowledgedInputs: this._config.maxUnacknowledgedInputs,
|
||||
reconciliationThreshold: this._config.reconciliationThreshold,
|
||||
reconciliationSpeed: this._config.reconciliationSpeed,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置移动输入
|
||||
* @en Set movement input
|
||||
*/
|
||||
setInput(x: number, y: number, actions?: string[]): void {
|
||||
this._currentInput = { x, y, actions }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取下一个要发送的输入(带序列号)
|
||||
* @en Get next input to send (with sequence number)
|
||||
*/
|
||||
getInputToSend(): PlayerInput | null {
|
||||
if (!this._prediction) return null
|
||||
|
||||
const input = this._prediction.getInputToSend()
|
||||
if (!input) return null
|
||||
|
||||
return {
|
||||
seq: input.sequence,
|
||||
frame: 0,
|
||||
timestamp: input.timestamp,
|
||||
moveDir: { x: input.input.x, y: input.input.y },
|
||||
actions: input.input.actions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理服务器同步数据进行校正
|
||||
* @en Process server sync data for reconciliation
|
||||
*/
|
||||
reconcileWithServer(data: SyncData): void {
|
||||
if (!this._prediction || this._localPlayerNetId < 0) return
|
||||
|
||||
// Find local player state in sync data
|
||||
const localState = data.entities.find(e => e.netId === this._localPlayerNetId)
|
||||
if (!localState || !localState.pos) return
|
||||
|
||||
const serverState: PredictedTransform = {
|
||||
x: localState.pos.x,
|
||||
y: localState.pos.y,
|
||||
rotation: localState.rot ?? 0,
|
||||
velocityX: localState.vel?.x ?? 0,
|
||||
velocityY: localState.vel?.y ?? 0,
|
||||
}
|
||||
|
||||
// Reconcile prediction with server state
|
||||
if (data.ackSeq !== undefined) {
|
||||
this._prediction.reconcile(
|
||||
serverState,
|
||||
data.ackSeq,
|
||||
(state) => ({ x: state.x, y: state.y }),
|
||||
Time.deltaTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
if (!this._config.enabled || !this._prediction) return
|
||||
|
||||
const deltaTime = Time.deltaTime
|
||||
|
||||
for (const entity of entities) {
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
|
||||
// Only process local player with authority
|
||||
if (!identity.bHasAuthority || identity.netId !== this._localPlayerNetId) continue
|
||||
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
|
||||
// Get current state
|
||||
const currentState: PredictedTransform = {
|
||||
x: transform.currentX,
|
||||
y: transform.currentY,
|
||||
rotation: transform.currentRotation,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
}
|
||||
|
||||
// Record input and get predicted state
|
||||
if (this._currentInput.x !== 0 || this._currentInput.y !== 0) {
|
||||
const predicted = this._prediction.recordInput(
|
||||
this._currentInput,
|
||||
currentState,
|
||||
deltaTime
|
||||
)
|
||||
|
||||
// Apply predicted position
|
||||
transform.currentX = predicted.x
|
||||
transform.currentY = predicted.y
|
||||
transform.currentRotation = predicted.rotation
|
||||
|
||||
// Update target to match (for rendering)
|
||||
transform.targetX = predicted.x
|
||||
transform.targetY = predicted.y
|
||||
transform.targetRotation = predicted.rotation
|
||||
|
||||
this._inputSequence = this._prediction.currentSequence
|
||||
}
|
||||
|
||||
// Apply correction offset smoothly
|
||||
const offset = this._prediction.correctionOffset
|
||||
if (Math.abs(offset.x) > 0.01 || Math.abs(offset.y) > 0.01) {
|
||||
transform.currentX += offset.x * deltaTime * 5
|
||||
transform.currentY += offset.y * deltaTime * 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置预测状态
|
||||
* @en Reset prediction state
|
||||
*/
|
||||
reset(): void {
|
||||
this._prediction?.clear()
|
||||
this._inputSequence = 0
|
||||
this._currentInput = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._prediction?.clear()
|
||||
this._prediction = null
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 工厂函数 | Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 创建网络预测系统
|
||||
* @en Create network prediction system
|
||||
*/
|
||||
export function createNetworkPredictionSystem(
|
||||
config?: Partial<NetworkPredictionConfig>
|
||||
): NetworkPredictionSystem {
|
||||
return new NetworkPredictionSystem(config)
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
/**
|
||||
* @zh 网络同步系统
|
||||
* @en Network Sync System
|
||||
*
|
||||
* @zh 处理网络实体的状态同步、快照缓冲和插值
|
||||
* @en Handles state synchronization, snapshot buffering, and interpolation for networked entities
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
||||
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||
import { NetworkTransform } from '../components/NetworkTransform'
|
||||
import type { SyncData, EntitySyncState } from '../protocol'
|
||||
import {
|
||||
SnapshotBuffer,
|
||||
createSnapshotBuffer,
|
||||
TransformInterpolator,
|
||||
createTransformInterpolator,
|
||||
type ITransformState,
|
||||
type ITransformStateWithVelocity,
|
||||
type IStateSnapshot,
|
||||
} from '../sync'
|
||||
|
||||
// =============================================================================
|
||||
// Types | 类型定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 同步消息接口
|
||||
* @en Sync message interface
|
||||
* @zh 同步消息接口(兼容旧版)
|
||||
* @en Sync message interface (for backwards compatibility)
|
||||
*/
|
||||
export interface SyncMessage {
|
||||
entities: Array<{
|
||||
@@ -14,25 +36,134 @@ export interface SyncMessage {
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 实体快照数据
|
||||
* @en Entity snapshot data
|
||||
*/
|
||||
interface EntitySnapshotData {
|
||||
buffer: SnapshotBuffer<ITransformStateWithVelocity>
|
||||
lastServerTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 同步系统配置
|
||||
* @en Sync system configuration
|
||||
*/
|
||||
export interface NetworkSyncConfig {
|
||||
/**
|
||||
* @zh 快照缓冲区大小
|
||||
* @en Snapshot buffer size
|
||||
*/
|
||||
bufferSize: number
|
||||
|
||||
/**
|
||||
* @zh 插值延迟(毫秒)
|
||||
* @en Interpolation delay in milliseconds
|
||||
*/
|
||||
interpolationDelay: number
|
||||
|
||||
/**
|
||||
* @zh 是否启用外推
|
||||
* @en Whether to enable extrapolation
|
||||
*/
|
||||
enableExtrapolation: boolean
|
||||
|
||||
/**
|
||||
* @zh 最大外推时间(毫秒)
|
||||
* @en Maximum extrapolation time in milliseconds
|
||||
*/
|
||||
maxExtrapolationTime: number
|
||||
|
||||
/**
|
||||
* @zh 使用赫尔米特插值(更平滑)
|
||||
* @en Use Hermite interpolation (smoother)
|
||||
*/
|
||||
useHermiteInterpolation: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: NetworkSyncConfig = {
|
||||
bufferSize: 30,
|
||||
interpolationDelay: 100,
|
||||
enableExtrapolation: true,
|
||||
maxExtrapolationTime: 200,
|
||||
useHermiteInterpolation: false,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NetworkSyncSystem | 网络同步系统
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 网络同步系统
|
||||
* @en Network sync system
|
||||
*
|
||||
* @zh 处理网络实体的状态同步和插值
|
||||
* @en Handles state synchronization and interpolation for networked entities
|
||||
* @zh 处理网络实体的状态同步和插值,支持快照缓冲、平滑插值和外推
|
||||
* @en Handles state synchronization and interpolation for networked entities,
|
||||
* supports snapshot buffering, smooth interpolation, and extrapolation
|
||||
*/
|
||||
export class NetworkSyncSystem extends EntitySystem {
|
||||
private _netIdToEntity: Map<number, number> = new Map()
|
||||
private readonly _netIdToEntity: Map<number, number> = new Map()
|
||||
private readonly _entitySnapshots: Map<number, EntitySnapshotData> = new Map()
|
||||
private readonly _interpolator: TransformInterpolator
|
||||
private readonly _config: NetworkSyncConfig
|
||||
|
||||
constructor() {
|
||||
private _serverTimeOffset: number = 0
|
||||
private _lastSyncTime: number = 0
|
||||
private _renderTime: number = 0
|
||||
|
||||
constructor(config?: Partial<NetworkSyncConfig>) {
|
||||
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||
this._interpolator = createTransformInterpolator()
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理同步消息
|
||||
* @en Handle sync message
|
||||
* @zh 获取配置
|
||||
* @en Get configuration
|
||||
*/
|
||||
get config(): Readonly<NetworkSyncConfig> {
|
||||
return this._config
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取服务器时间偏移
|
||||
* @en Get server time offset
|
||||
*/
|
||||
get serverTimeOffset(): number {
|
||||
return this._serverTimeOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前渲染时间
|
||||
* @en Get current render time
|
||||
*/
|
||||
get renderTime(): number {
|
||||
return this._renderTime
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理同步消息(新版,带时间戳)
|
||||
* @en Handle sync message (new version with timestamp)
|
||||
*/
|
||||
handleSyncData(data: SyncData): void {
|
||||
const serverTime = data.timestamp
|
||||
|
||||
// Update server time offset
|
||||
const clientTime = Date.now()
|
||||
this._serverTimeOffset = serverTime - clientTime
|
||||
this._lastSyncTime = clientTime
|
||||
|
||||
for (const state of data.entities) {
|
||||
this._processEntityState(state, serverTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理同步消息(兼容旧版)
|
||||
* @en Handle sync message (backwards compatible)
|
||||
*/
|
||||
handleSync(msg: SyncMessage): void {
|
||||
const now = Date.now()
|
||||
for (const state of msg.entities) {
|
||||
const entityId = this._netIdToEntity.get(state.netId)
|
||||
if (entityId === undefined) continue
|
||||
@@ -44,22 +175,133 @@ export class NetworkSyncSystem extends EntitySystem {
|
||||
if (transform && state.pos) {
|
||||
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
|
||||
}
|
||||
|
||||
// Also add to snapshot buffer for interpolation
|
||||
this._processEntityState({
|
||||
netId: state.netId,
|
||||
pos: state.pos,
|
||||
rot: state.rot,
|
||||
}, now)
|
||||
}
|
||||
}
|
||||
|
||||
private _processEntityState(state: EntitySyncState, serverTime: number): void {
|
||||
const entityId = this._netIdToEntity.get(state.netId)
|
||||
if (entityId === undefined) return
|
||||
|
||||
// Get or create snapshot buffer
|
||||
let snapshotData = this._entitySnapshots.get(state.netId)
|
||||
if (!snapshotData) {
|
||||
snapshotData = {
|
||||
buffer: createSnapshotBuffer<ITransformStateWithVelocity>(
|
||||
this._config.bufferSize,
|
||||
this._config.interpolationDelay
|
||||
),
|
||||
lastServerTime: 0,
|
||||
}
|
||||
this._entitySnapshots.set(state.netId, snapshotData)
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
const transformState: ITransformStateWithVelocity = {
|
||||
x: state.pos?.x ?? 0,
|
||||
y: state.pos?.y ?? 0,
|
||||
rotation: state.rot ?? 0,
|
||||
velocityX: state.vel?.x ?? 0,
|
||||
velocityY: state.vel?.y ?? 0,
|
||||
angularVelocity: state.angVel ?? 0,
|
||||
}
|
||||
|
||||
const snapshot: IStateSnapshot<ITransformStateWithVelocity> = {
|
||||
timestamp: serverTime,
|
||||
state: transformState,
|
||||
}
|
||||
|
||||
snapshotData.buffer.push(snapshot)
|
||||
snapshotData.lastServerTime = serverTime
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const deltaTime = Time.deltaTime
|
||||
const clientTime = Date.now()
|
||||
|
||||
// Calculate render time (current time adjusted for server offset)
|
||||
this._renderTime = clientTime + this._serverTimeOffset
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = this.requireComponent(entity, NetworkTransform)
|
||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||
|
||||
if (!identity.bHasAuthority && transform.bInterpolate) {
|
||||
this._interpolate(transform, deltaTime)
|
||||
// Skip entities with authority (local player handles their own movement)
|
||||
if (identity.bHasAuthority) continue
|
||||
|
||||
if (transform.bInterpolate) {
|
||||
this._interpolateEntity(identity.netId, transform, deltaTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _interpolateEntity(
|
||||
netId: number,
|
||||
transform: NetworkTransform,
|
||||
deltaTime: number
|
||||
): void {
|
||||
const snapshotData = this._entitySnapshots.get(netId)
|
||||
|
||||
if (snapshotData && snapshotData.buffer.size >= 2) {
|
||||
// Use snapshot buffer for interpolation
|
||||
const result = snapshotData.buffer.getInterpolationSnapshots(this._renderTime)
|
||||
|
||||
if (result) {
|
||||
const [prev, next, t] = result
|
||||
const interpolated = this._interpolator.interpolate(prev.state, next.state, t)
|
||||
|
||||
transform.currentX = interpolated.x
|
||||
transform.currentY = interpolated.y
|
||||
transform.currentRotation = interpolated.rotation
|
||||
|
||||
// Update target for compatibility
|
||||
transform.targetX = next.state.x
|
||||
transform.targetY = next.state.y
|
||||
transform.targetRotation = next.state.rotation
|
||||
return
|
||||
}
|
||||
|
||||
// Extrapolation if enabled and we have velocity data
|
||||
if (this._config.enableExtrapolation) {
|
||||
const latest = snapshotData.buffer.getLatest()
|
||||
if (latest) {
|
||||
const timeSinceLastSnapshot = this._renderTime - latest.timestamp
|
||||
if (timeSinceLastSnapshot > 0 && timeSinceLastSnapshot < this._config.maxExtrapolationTime) {
|
||||
const extrapolated = this._interpolator.extrapolate(
|
||||
latest.state,
|
||||
timeSinceLastSnapshot / 1000
|
||||
)
|
||||
transform.currentX = extrapolated.x
|
||||
transform.currentY = extrapolated.y
|
||||
transform.currentRotation = extrapolated.rotation
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: simple lerp towards target
|
||||
this._simpleLerp(transform, deltaTime)
|
||||
}
|
||||
|
||||
private _simpleLerp(transform: NetworkTransform, deltaTime: number): void {
|
||||
const t = Math.min(1, transform.lerpSpeed * deltaTime)
|
||||
|
||||
transform.currentX += (transform.targetX - transform.currentX) * t
|
||||
transform.currentY += (transform.targetY - transform.currentY) * t
|
||||
|
||||
let angleDiff = transform.targetRotation - transform.currentRotation
|
||||
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
|
||||
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
|
||||
transform.currentRotation += angleDiff * t
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册网络实体
|
||||
* @en Register network entity
|
||||
@@ -74,6 +316,7 @@ export class NetworkSyncSystem extends EntitySystem {
|
||||
*/
|
||||
unregisterEntity(netId: number): void {
|
||||
this._netIdToEntity.delete(netId)
|
||||
this._entitySnapshots.delete(netId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,19 +327,26 @@ export class NetworkSyncSystem extends EntitySystem {
|
||||
return this._netIdToEntity.get(netId)
|
||||
}
|
||||
|
||||
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
|
||||
const t = Math.min(1, transform.lerpSpeed * deltaTime)
|
||||
/**
|
||||
* @zh 获取实体的快照缓冲区
|
||||
* @en Get entity's snapshot buffer
|
||||
*/
|
||||
getSnapshotBuffer(netId: number): SnapshotBuffer<ITransformStateWithVelocity> | undefined {
|
||||
return this._entitySnapshots.get(netId)?.buffer
|
||||
}
|
||||
|
||||
transform.currentX += (transform.targetX - transform.currentX) * t
|
||||
transform.currentY += (transform.targetY - transform.currentY) * t
|
||||
|
||||
let angleDiff = transform.targetRotation - transform.currentRotation
|
||||
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
|
||||
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
|
||||
transform.currentRotation += angleDiff * t
|
||||
/**
|
||||
* @zh 清空所有快照缓冲
|
||||
* @en Clear all snapshot buffers
|
||||
*/
|
||||
clearSnapshots(): void {
|
||||
for (const data of this._entitySnapshots.values()) {
|
||||
data.buffer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
this._netIdToEntity.clear()
|
||||
this._entitySnapshots.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { NetworkService } from './services/NetworkService';
|
||||
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
|
||||
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
|
||||
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
|
||||
import type { NetworkPredictionSystem } from './systems/NetworkPredictionSystem';
|
||||
import type { NetworkAOISystem } from './systems/NetworkAOISystem';
|
||||
|
||||
// ============================================================================
|
||||
// Network 模块导出的令牌 | Tokens exported by Network module
|
||||
@@ -36,3 +38,15 @@ export const NetworkSpawnSystemToken = createServiceToken<NetworkSpawnSystem>('n
|
||||
* Network input system token
|
||||
*/
|
||||
export const NetworkInputSystemToken = createServiceToken<NetworkInputSystem>('networkInputSystem');
|
||||
|
||||
/**
|
||||
* 网络预测系统令牌
|
||||
* Network prediction system token
|
||||
*/
|
||||
export const NetworkPredictionSystemToken = createServiceToken<NetworkPredictionSystem>('networkPredictionSystem');
|
||||
|
||||
/**
|
||||
* 网络 AOI 系统令牌
|
||||
* Network AOI system token
|
||||
*/
|
||||
export const NetworkAOISystemToken = createServiceToken<NetworkAOISystem>('networkAOISystem');
|
||||
|
||||
45
packages/framework/transaction/CHANGELOG.md
Normal file
45
packages/framework/transaction/CHANGELOG.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @esengine/transaction
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#381](https://github.com/esengine/esengine/pull/381) [`d4cef82`](https://github.com/esengine/esengine/commit/d4cef828e1dc1475e8483d40eb1d800c607cf3b6) Thanks [@esengine](https://github.com/esengine)! - feat(transaction): 添加游戏事务系统 | add game transaction system
|
||||
|
||||
**@esengine/transaction** - 游戏事务系统 | Game transaction system
|
||||
|
||||
### 核心功能 | Core Features
|
||||
- **TransactionManager** - 事务生命周期管理 | Transaction lifecycle management
|
||||
- begin()/run() 创建事务 | Create transactions
|
||||
- 分布式锁支持 | Distributed lock support
|
||||
- 自动恢复未完成事务 | Auto-recover pending transactions
|
||||
- **TransactionContext** - 事务上下文 | Transaction context
|
||||
- 操作链式添加 | Chain operation additions
|
||||
- 上下文数据共享 | Context data sharing
|
||||
- 超时控制 | Timeout control
|
||||
- **Saga 模式** - 补偿式事务 | Compensating transactions
|
||||
- execute/compensate 成对操作 | Paired execute/compensate
|
||||
- 自动回滚失败事务 | Auto-rollback on failure
|
||||
|
||||
### 存储实现 | Storage Implementations
|
||||
- **MemoryStorage** - 内存存储,用于开发测试 | In-memory for dev/testing
|
||||
- **RedisStorage** - Redis 分布式锁和缓存 | Redis distributed lock & cache
|
||||
- **MongoStorage** - MongoDB 持久化事务日志 | MongoDB persistent transaction logs
|
||||
|
||||
### 内置操作 | Built-in Operations
|
||||
- **CurrencyOperation** - 货币增减操作 | Currency add/deduct
|
||||
- **InventoryOperation** - 背包物品操作 | Inventory item operations
|
||||
- **TradeOperation** - 玩家交易操作 | Player trade operations
|
||||
|
||||
### 分布式事务 | Distributed Transactions
|
||||
- **SagaOrchestrator** - 跨服务器 Saga 编排 | Cross-server Saga orchestration
|
||||
- 完整的 Saga 日志记录 | Complete Saga logging
|
||||
- 未完成 Saga 恢复 | Incomplete Saga recovery
|
||||
|
||||
### Room 集成 | Room Integration
|
||||
- **withTransactions()** - Room mixin 扩展 | Room mixin extension
|
||||
- **TransactionRoom** - 预配置的事务 Room 基类 | Pre-configured transaction Room base
|
||||
|
||||
### 文档 | Documentation
|
||||
- 完整的中英文文档 | Complete bilingual documentation
|
||||
- 核心概念、存储层、操作、分布式事务 | Core concepts, storage, operations, distributed
|
||||
23
packages/framework/transaction/module.json
Normal file
23
packages/framework/transaction/module.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "transaction",
|
||||
"name": "@esengine/transaction",
|
||||
"globalKey": "transaction",
|
||||
"displayName": "Transaction System",
|
||||
"description": "游戏事务系统,支持商店购买、玩家交易、分布式事务 | Game transaction system for shop, trading, distributed transactions",
|
||||
"version": "1.0.0",
|
||||
"category": "Other",
|
||||
"icon": "Receipt",
|
||||
"tags": ["transaction", "distributed", "saga", "database"],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["nodejs"],
|
||||
"dependencies": ["core", "server"],
|
||||
"exports": {
|
||||
"services": ["TransactionManager"],
|
||||
"interfaces": ["ITransactionStorage", "ITransactionOperation"]
|
||||
},
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
50
packages/framework/transaction/package.json
Normal file
50
packages/framework/transaction/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/transaction",
|
||||
"version": "1.1.0",
|
||||
"description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"module.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/server": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ioredis": "^5.3.0",
|
||||
"mongodb": "^6.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ioredis": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"rimraf": "^5.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
286
packages/framework/transaction/src/core/TransactionContext.ts
Normal file
286
packages/framework/transaction/src/core/TransactionContext.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @zh 事务上下文实现
|
||||
* @en Transaction context implementation
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionContext,
|
||||
ITransactionOperation,
|
||||
ITransactionStorage,
|
||||
TransactionState,
|
||||
TransactionResult,
|
||||
TransactionOptions,
|
||||
TransactionLog,
|
||||
OperationLog,
|
||||
OperationResult,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* @zh 生成唯一 ID
|
||||
* @en Generate unique ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务上下文
|
||||
* @en Transaction context
|
||||
*
|
||||
* @zh 封装事务的状态、操作和执行逻辑
|
||||
* @en Encapsulates transaction state, operations, and execution logic
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ctx = new TransactionContext({ timeout: 5000 })
|
||||
* ctx.addOperation(new DeductCurrency({ playerId: '1', amount: 100 }))
|
||||
* ctx.addOperation(new AddItem({ playerId: '1', itemId: 'sword' }))
|
||||
* const result = await ctx.execute()
|
||||
* ```
|
||||
*/
|
||||
export class TransactionContext implements ITransactionContext {
|
||||
private _id: string
|
||||
private _state: TransactionState = 'pending'
|
||||
private _timeout: number
|
||||
private _operations: ITransactionOperation[] = []
|
||||
private _storage: ITransactionStorage | null
|
||||
private _metadata: Record<string, unknown>
|
||||
private _contextData: Map<string, unknown> = new Map()
|
||||
private _startTime: number = 0
|
||||
private _distributed: boolean
|
||||
|
||||
constructor(options: TransactionOptions & { storage?: ITransactionStorage } = {}) {
|
||||
this._id = generateId()
|
||||
this._timeout = options.timeout ?? 30000
|
||||
this._storage = options.storage ?? null
|
||||
this._metadata = options.metadata ?? {}
|
||||
this._distributed = options.distributed ?? false
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 只读属性 | Readonly properties
|
||||
// =========================================================================
|
||||
|
||||
get id(): string {
|
||||
return this._id
|
||||
}
|
||||
|
||||
get state(): TransactionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get timeout(): number {
|
||||
return this._timeout
|
||||
}
|
||||
|
||||
get operations(): ReadonlyArray<ITransactionOperation> {
|
||||
return this._operations
|
||||
}
|
||||
|
||||
get storage(): ITransactionStorage | null {
|
||||
return this._storage
|
||||
}
|
||||
|
||||
get metadata(): Record<string, unknown> {
|
||||
return this._metadata
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 公共方法 | Public methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 添加操作
|
||||
* @en Add operation
|
||||
*/
|
||||
addOperation<T extends ITransactionOperation>(operation: T): this {
|
||||
if (this._state !== 'pending') {
|
||||
throw new Error(`Cannot add operation to transaction in state: ${this._state}`)
|
||||
}
|
||||
this._operations.push(operation)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 执行事务
|
||||
* @en Execute transaction
|
||||
*/
|
||||
async execute<T = unknown>(): Promise<TransactionResult<T>> {
|
||||
if (this._state !== 'pending') {
|
||||
return {
|
||||
success: false,
|
||||
transactionId: this._id,
|
||||
results: [],
|
||||
error: `Transaction already in state: ${this._state}`,
|
||||
duration: 0,
|
||||
}
|
||||
}
|
||||
|
||||
this._startTime = Date.now()
|
||||
this._state = 'executing'
|
||||
|
||||
const results: OperationResult[] = []
|
||||
let executedCount = 0
|
||||
|
||||
try {
|
||||
await this._saveLog()
|
||||
|
||||
for (let i = 0; i < this._operations.length; i++) {
|
||||
if (this._isTimedOut()) {
|
||||
throw new Error('Transaction timed out')
|
||||
}
|
||||
|
||||
const op = this._operations[i]
|
||||
|
||||
const isValid = await op.validate(this)
|
||||
if (!isValid) {
|
||||
throw new Error(`Validation failed for operation: ${op.name}`)
|
||||
}
|
||||
|
||||
const result = await op.execute(this)
|
||||
results.push(result)
|
||||
executedCount++
|
||||
|
||||
await this._updateOperationLog(i, 'executed')
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? `Operation ${op.name} failed`)
|
||||
}
|
||||
}
|
||||
|
||||
this._state = 'committed'
|
||||
await this._updateTransactionState('committed')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transactionId: this._id,
|
||||
results,
|
||||
data: this._collectResultData(results) as T,
|
||||
duration: Date.now() - this._startTime,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
await this._compensate(executedCount - 1)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
transactionId: this._id,
|
||||
results,
|
||||
error: errorMessage,
|
||||
duration: Date.now() - this._startTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 手动回滚事务
|
||||
* @en Manually rollback transaction
|
||||
*/
|
||||
async rollback(): Promise<void> {
|
||||
if (this._state === 'committed' || this._state === 'rolledback') {
|
||||
return
|
||||
}
|
||||
|
||||
await this._compensate(this._operations.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取上下文数据
|
||||
* @en Get context data
|
||||
*/
|
||||
get<T>(key: string): T | undefined {
|
||||
return this._contextData.get(key) as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置上下文数据
|
||||
* @en Set context data
|
||||
*/
|
||||
set<T>(key: string, value: T): void {
|
||||
this._contextData.set(key, value)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private methods
|
||||
// =========================================================================
|
||||
|
||||
private _isTimedOut(): boolean {
|
||||
return Date.now() - this._startTime > this._timeout
|
||||
}
|
||||
|
||||
private async _compensate(fromIndex: number): Promise<void> {
|
||||
this._state = 'rolledback'
|
||||
|
||||
for (let i = fromIndex; i >= 0; i--) {
|
||||
const op = this._operations[i]
|
||||
try {
|
||||
await op.compensate(this)
|
||||
await this._updateOperationLog(i, 'compensated')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
await this._updateOperationLog(i, 'failed', errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
await this._updateTransactionState('rolledback')
|
||||
}
|
||||
|
||||
private async _saveLog(): Promise<void> {
|
||||
if (!this._storage) return
|
||||
|
||||
const log: TransactionLog = {
|
||||
id: this._id,
|
||||
state: this._state,
|
||||
createdAt: this._startTime,
|
||||
updatedAt: this._startTime,
|
||||
timeout: this._timeout,
|
||||
operations: this._operations.map((op) => ({
|
||||
name: op.name,
|
||||
data: op.data,
|
||||
state: 'pending' as const,
|
||||
})),
|
||||
metadata: this._metadata,
|
||||
distributed: this._distributed,
|
||||
}
|
||||
|
||||
await this._storage.saveTransaction(log)
|
||||
}
|
||||
|
||||
private async _updateTransactionState(state: TransactionState): Promise<void> {
|
||||
this._state = state
|
||||
if (this._storage) {
|
||||
await this._storage.updateTransactionState(this._id, state)
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateOperationLog(
|
||||
index: number,
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
if (this._storage) {
|
||||
await this._storage.updateOperationState(this._id, index, state, error)
|
||||
}
|
||||
}
|
||||
|
||||
private _collectResultData(results: OperationResult[]): unknown {
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const result of results) {
|
||||
if (result.data !== undefined) {
|
||||
Object.assign(data, result.data)
|
||||
}
|
||||
}
|
||||
return Object.keys(data).length > 0 ? data : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建事务上下文
|
||||
* @en Create transaction context
|
||||
*/
|
||||
export function createTransactionContext(
|
||||
options: TransactionOptions & { storage?: ITransactionStorage } = {}
|
||||
): ITransactionContext {
|
||||
return new TransactionContext(options)
|
||||
}
|
||||
255
packages/framework/transaction/src/core/TransactionManager.ts
Normal file
255
packages/framework/transaction/src/core/TransactionManager.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @zh 事务管理器
|
||||
* @en Transaction manager
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionContext,
|
||||
ITransactionStorage,
|
||||
TransactionManagerConfig,
|
||||
TransactionOptions,
|
||||
TransactionLog,
|
||||
TransactionResult,
|
||||
} from './types.js'
|
||||
import { TransactionContext } from './TransactionContext.js'
|
||||
|
||||
/**
|
||||
* @zh 事务管理器
|
||||
* @en Transaction manager
|
||||
*
|
||||
* @zh 管理事务的创建、执行和恢复
|
||||
* @en Manages transaction creation, execution, and recovery
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const manager = new TransactionManager({
|
||||
* storage: new RedisStorage({ url: 'redis://localhost:6379' }),
|
||||
* defaultTimeout: 10000,
|
||||
* })
|
||||
*
|
||||
* const tx = manager.begin({ timeout: 5000 })
|
||||
* tx.addOperation(new DeductCurrency({ ... }))
|
||||
* tx.addOperation(new AddItem({ ... }))
|
||||
*
|
||||
* const result = await tx.execute()
|
||||
* ```
|
||||
*/
|
||||
export class TransactionManager {
|
||||
private _storage: ITransactionStorage | null
|
||||
private _defaultTimeout: number
|
||||
private _serverId: string
|
||||
private _autoRecover: boolean
|
||||
private _activeTransactions: Map<string, ITransactionContext> = new Map()
|
||||
|
||||
constructor(config: TransactionManagerConfig = {}) {
|
||||
this._storage = config.storage ?? null
|
||||
this._defaultTimeout = config.defaultTimeout ?? 30000
|
||||
this._serverId = config.serverId ?? this._generateServerId()
|
||||
this._autoRecover = config.autoRecover ?? true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 只读属性 | Readonly properties
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 服务器 ID
|
||||
* @en Server ID
|
||||
*/
|
||||
get serverId(): string {
|
||||
return this._serverId
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 存储实例
|
||||
* @en Storage instance
|
||||
*/
|
||||
get storage(): ITransactionStorage | null {
|
||||
return this._storage
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 活跃事务数量
|
||||
* @en Active transaction count
|
||||
*/
|
||||
get activeCount(): number {
|
||||
return this._activeTransactions.size
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 公共方法 | Public methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 开始新事务
|
||||
* @en Begin new transaction
|
||||
*
|
||||
* @param options - @zh 事务选项 @en Transaction options
|
||||
* @returns @zh 事务上下文 @en Transaction context
|
||||
*/
|
||||
begin(options: TransactionOptions = {}): ITransactionContext {
|
||||
const ctx = new TransactionContext({
|
||||
timeout: options.timeout ?? this._defaultTimeout,
|
||||
storage: this._storage ?? undefined,
|
||||
metadata: {
|
||||
...options.metadata,
|
||||
serverId: this._serverId,
|
||||
},
|
||||
distributed: options.distributed,
|
||||
})
|
||||
|
||||
this._activeTransactions.set(ctx.id, ctx)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 执行事务(便捷方法)
|
||||
* @en Execute transaction (convenience method)
|
||||
*
|
||||
* @param builder - @zh 事务构建函数 @en Transaction builder function
|
||||
* @param options - @zh 事务选项 @en Transaction options
|
||||
* @returns @zh 事务结果 @en Transaction result
|
||||
*/
|
||||
async run<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options: TransactionOptions = {}
|
||||
): Promise<TransactionResult<T>> {
|
||||
const ctx = this.begin(options)
|
||||
|
||||
try {
|
||||
await builder(ctx)
|
||||
const result = await ctx.execute<T>()
|
||||
return result
|
||||
} finally {
|
||||
this._activeTransactions.delete(ctx.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取活跃事务
|
||||
* @en Get active transaction
|
||||
*/
|
||||
getTransaction(id: string): ITransactionContext | undefined {
|
||||
return this._activeTransactions.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 恢复未完成的事务
|
||||
* @en Recover pending transactions
|
||||
*/
|
||||
async recover(): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
|
||||
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId)
|
||||
let recoveredCount = 0
|
||||
|
||||
for (const log of pendingTransactions) {
|
||||
try {
|
||||
await this._recoverTransaction(log)
|
||||
recoveredCount++
|
||||
} catch (error) {
|
||||
console.error(`Failed to recover transaction ${log.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取分布式锁
|
||||
* @en Acquire distributed lock
|
||||
*/
|
||||
async acquireLock(key: string, ttl: number = 10000): Promise<string | null> {
|
||||
if (!this._storage) return null
|
||||
return this._storage.acquireLock(key, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 释放分布式锁
|
||||
* @en Release distributed lock
|
||||
*/
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
if (!this._storage) return false
|
||||
return this._storage.releaseLock(key, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 使用分布式锁执行
|
||||
* @en Execute with distributed lock
|
||||
*/
|
||||
async withLock<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
ttl: number = 10000
|
||||
): Promise<T> {
|
||||
const token = await this.acquireLock(key, ttl)
|
||||
if (!token) {
|
||||
throw new Error(`Failed to acquire lock for key: ${key}`)
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await this.releaseLock(key, token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理已完成的事务日志
|
||||
* @en Clean up completed transaction logs
|
||||
*/
|
||||
async cleanup(beforeTimestamp?: number): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
|
||||
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000 // 默认清理24小时前
|
||||
|
||||
const pendingTransactions = await this._storage.getPendingTransactions()
|
||||
let cleanedCount = 0
|
||||
|
||||
for (const log of pendingTransactions) {
|
||||
if (
|
||||
log.createdAt < timestamp &&
|
||||
(log.state === 'committed' || log.state === 'rolledback')
|
||||
) {
|
||||
await this._storage.deleteTransaction(log.id)
|
||||
cleanedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 私有方法 | Private methods
|
||||
// =========================================================================
|
||||
|
||||
private _generateServerId(): string {
|
||||
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`
|
||||
}
|
||||
|
||||
private async _recoverTransaction(log: TransactionLog): Promise<void> {
|
||||
if (log.state === 'executing') {
|
||||
const executedOps = log.operations.filter((op) => op.state === 'executed')
|
||||
|
||||
if (executedOps.length > 0 && this._storage) {
|
||||
for (let i = executedOps.length - 1; i >= 0; i--) {
|
||||
await this._storage.updateOperationState(log.id, i, 'compensated')
|
||||
}
|
||||
await this._storage.updateTransactionState(log.id, 'rolledback')
|
||||
} else {
|
||||
await this._storage?.updateTransactionState(log.id, 'failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建事务管理器
|
||||
* @en Create transaction manager
|
||||
*/
|
||||
export function createTransactionManager(
|
||||
config: TransactionManagerConfig = {}
|
||||
): TransactionManager {
|
||||
return new TransactionManager(config)
|
||||
}
|
||||
20
packages/framework/transaction/src/core/index.ts
Normal file
20
packages/framework/transaction/src/core/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @zh 核心模块导出
|
||||
* @en Core module exports
|
||||
*/
|
||||
|
||||
export type {
|
||||
TransactionState,
|
||||
OperationResult,
|
||||
TransactionResult,
|
||||
OperationLog,
|
||||
TransactionLog,
|
||||
TransactionOptions,
|
||||
TransactionManagerConfig,
|
||||
ITransactionStorage,
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
} from './types.js'
|
||||
|
||||
export { TransactionContext, createTransactionContext } from './TransactionContext.js'
|
||||
export { TransactionManager, createTransactionManager } from './TransactionManager.js'
|
||||
484
packages/framework/transaction/src/core/types.ts
Normal file
484
packages/framework/transaction/src/core/types.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* @zh 事务系统核心类型定义
|
||||
* @en Transaction system core type definitions
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 事务状态 | Transaction State
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 事务状态
|
||||
* @en Transaction state
|
||||
*/
|
||||
export type TransactionState =
|
||||
| 'pending' // 等待执行 | Waiting to execute
|
||||
| 'executing' // 执行中 | Executing
|
||||
| 'committed' // 已提交 | Committed
|
||||
| 'rolledback' // 已回滚 | Rolled back
|
||||
| 'failed' // 失败 | Failed
|
||||
|
||||
// =============================================================================
|
||||
// 操作结果 | Operation Result
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 操作结果
|
||||
* @en Operation result
|
||||
*/
|
||||
export interface OperationResult<T = unknown> {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether succeeded
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* @zh 返回数据
|
||||
* @en Return data
|
||||
*/
|
||||
data?: T
|
||||
|
||||
/**
|
||||
* @zh 错误信息
|
||||
* @en Error message
|
||||
*/
|
||||
error?: string
|
||||
|
||||
/**
|
||||
* @zh 错误代码
|
||||
* @en Error code
|
||||
*/
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务结果
|
||||
* @en Transaction result
|
||||
*/
|
||||
export interface TransactionResult<T = unknown> {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether succeeded
|
||||
*/
|
||||
success: boolean
|
||||
|
||||
/**
|
||||
* @zh 事务 ID
|
||||
* @en Transaction ID
|
||||
*/
|
||||
transactionId: string
|
||||
|
||||
/**
|
||||
* @zh 操作结果列表
|
||||
* @en Operation results
|
||||
*/
|
||||
results: OperationResult[]
|
||||
|
||||
/**
|
||||
* @zh 最终数据
|
||||
* @en Final data
|
||||
*/
|
||||
data?: T
|
||||
|
||||
/**
|
||||
* @zh 错误信息
|
||||
* @en Error message
|
||||
*/
|
||||
error?: string
|
||||
|
||||
/**
|
||||
* @zh 执行时间(毫秒)
|
||||
* @en Execution time in milliseconds
|
||||
*/
|
||||
duration: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 事务日志 | Transaction Log
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 操作日志
|
||||
* @en Operation log
|
||||
*/
|
||||
export interface OperationLog {
|
||||
/**
|
||||
* @zh 操作名称
|
||||
* @en Operation name
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* @zh 操作数据
|
||||
* @en Operation data
|
||||
*/
|
||||
data: unknown
|
||||
|
||||
/**
|
||||
* @zh 操作状态
|
||||
* @en Operation state
|
||||
*/
|
||||
state: 'pending' | 'executed' | 'compensated' | 'failed'
|
||||
|
||||
/**
|
||||
* @zh 执行时间
|
||||
* @en Execution timestamp
|
||||
*/
|
||||
executedAt?: number
|
||||
|
||||
/**
|
||||
* @zh 补偿时间
|
||||
* @en Compensation timestamp
|
||||
*/
|
||||
compensatedAt?: number
|
||||
|
||||
/**
|
||||
* @zh 错误信息
|
||||
* @en Error message
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务日志
|
||||
* @en Transaction log
|
||||
*/
|
||||
export interface TransactionLog {
|
||||
/**
|
||||
* @zh 事务 ID
|
||||
* @en Transaction ID
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* @zh 事务状态
|
||||
* @en Transaction state
|
||||
*/
|
||||
state: TransactionState
|
||||
|
||||
/**
|
||||
* @zh 创建时间
|
||||
* @en Creation timestamp
|
||||
*/
|
||||
createdAt: number
|
||||
|
||||
/**
|
||||
* @zh 更新时间
|
||||
* @en Update timestamp
|
||||
*/
|
||||
updatedAt: number
|
||||
|
||||
/**
|
||||
* @zh 超时时间(毫秒)
|
||||
* @en Timeout in milliseconds
|
||||
*/
|
||||
timeout: number
|
||||
|
||||
/**
|
||||
* @zh 操作日志列表
|
||||
* @en Operation logs
|
||||
*/
|
||||
operations: OperationLog[]
|
||||
|
||||
/**
|
||||
* @zh 元数据
|
||||
* @en Metadata
|
||||
*/
|
||||
metadata?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* @zh 是否分布式事务
|
||||
* @en Whether distributed transaction
|
||||
*/
|
||||
distributed?: boolean
|
||||
|
||||
/**
|
||||
* @zh 参与的服务器列表
|
||||
* @en Participating servers
|
||||
*/
|
||||
participants?: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 事务配置 | Transaction Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 事务选项
|
||||
* @en Transaction options
|
||||
*/
|
||||
export interface TransactionOptions {
|
||||
/**
|
||||
* @zh 超时时间(毫秒),默认 30000
|
||||
* @en Timeout in milliseconds, default 30000
|
||||
*/
|
||||
timeout?: number
|
||||
|
||||
/**
|
||||
* @zh 是否分布式事务
|
||||
* @en Whether distributed transaction
|
||||
*/
|
||||
distributed?: boolean
|
||||
|
||||
/**
|
||||
* @zh 元数据
|
||||
* @en Metadata
|
||||
*/
|
||||
metadata?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* @zh 重试次数,默认 0
|
||||
* @en Retry count, default 0
|
||||
*/
|
||||
retryCount?: number
|
||||
|
||||
/**
|
||||
* @zh 重试间隔(毫秒),默认 1000
|
||||
* @en Retry interval in milliseconds, default 1000
|
||||
*/
|
||||
retryInterval?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务管理器配置
|
||||
* @en Transaction manager configuration
|
||||
*/
|
||||
export interface TransactionManagerConfig {
|
||||
/**
|
||||
* @zh 存储实例
|
||||
* @en Storage instance
|
||||
*/
|
||||
storage?: ITransactionStorage
|
||||
|
||||
/**
|
||||
* @zh 默认超时时间(毫秒)
|
||||
* @en Default timeout in milliseconds
|
||||
*/
|
||||
defaultTimeout?: number
|
||||
|
||||
/**
|
||||
* @zh 服务器 ID(分布式用)
|
||||
* @en Server ID for distributed transactions
|
||||
*/
|
||||
serverId?: string
|
||||
|
||||
/**
|
||||
* @zh 是否自动恢复未完成事务
|
||||
* @en Whether to auto-recover pending transactions
|
||||
*/
|
||||
autoRecover?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 存储接口 | Storage Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 事务存储接口
|
||||
* @en Transaction storage interface
|
||||
*/
|
||||
export interface ITransactionStorage {
|
||||
/**
|
||||
* @zh 获取分布式锁
|
||||
* @en Acquire distributed lock
|
||||
*
|
||||
* @param key - @zh 锁的键 @en Lock key
|
||||
* @param ttl - @zh 锁的生存时间(毫秒) @en Lock TTL in milliseconds
|
||||
* @returns @zh 锁令牌,获取失败返回 null @en Lock token, null if failed
|
||||
*/
|
||||
acquireLock(key: string, ttl: number): Promise<string | null>
|
||||
|
||||
/**
|
||||
* @zh 释放分布式锁
|
||||
* @en Release distributed lock
|
||||
*
|
||||
* @param key - @zh 锁的键 @en Lock key
|
||||
* @param token - @zh 锁令牌 @en Lock token
|
||||
* @returns @zh 是否成功释放 @en Whether released successfully
|
||||
*/
|
||||
releaseLock(key: string, token: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* @zh 保存事务日志
|
||||
* @en Save transaction log
|
||||
*/
|
||||
saveTransaction(tx: TransactionLog): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 获取事务日志
|
||||
* @en Get transaction log
|
||||
*/
|
||||
getTransaction(id: string): Promise<TransactionLog | null>
|
||||
|
||||
/**
|
||||
* @zh 更新事务状态
|
||||
* @en Update transaction state
|
||||
*/
|
||||
updateTransactionState(id: string, state: TransactionState): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 更新操作状态
|
||||
* @en Update operation state
|
||||
*/
|
||||
updateOperationState(
|
||||
transactionId: string,
|
||||
operationIndex: number,
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 获取待恢复的事务列表
|
||||
* @en Get pending transactions for recovery
|
||||
*/
|
||||
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>
|
||||
|
||||
/**
|
||||
* @zh 删除事务日志
|
||||
* @en Delete transaction log
|
||||
*/
|
||||
deleteTransaction(id: string): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 获取数据
|
||||
* @en Get data
|
||||
*/
|
||||
get<T>(key: string): Promise<T | null>
|
||||
|
||||
/**
|
||||
* @zh 设置数据
|
||||
* @en Set data
|
||||
*/
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 删除数据
|
||||
* @en Delete data
|
||||
*/
|
||||
delete(key: string): Promise<boolean>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 操作接口 | Operation Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 事务操作接口
|
||||
* @en Transaction operation interface
|
||||
*/
|
||||
export interface ITransactionOperation<TData = unknown, TResult = unknown> {
|
||||
/**
|
||||
* @zh 操作名称
|
||||
* @en Operation name
|
||||
*/
|
||||
readonly name: string
|
||||
|
||||
/**
|
||||
* @zh 操作数据
|
||||
* @en Operation data
|
||||
*/
|
||||
readonly data: TData
|
||||
|
||||
/**
|
||||
* @zh 验证前置条件
|
||||
* @en Validate preconditions
|
||||
*
|
||||
* @param ctx - @zh 事务上下文 @en Transaction context
|
||||
* @returns @zh 是否验证通过 @en Whether validation passed
|
||||
*/
|
||||
validate(ctx: ITransactionContext): Promise<boolean>
|
||||
|
||||
/**
|
||||
* @zh 执行操作
|
||||
* @en Execute operation
|
||||
*
|
||||
* @param ctx - @zh 事务上下文 @en Transaction context
|
||||
* @returns @zh 操作结果 @en Operation result
|
||||
*/
|
||||
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>
|
||||
|
||||
/**
|
||||
* @zh 补偿操作(回滚)
|
||||
* @en Compensate operation (rollback)
|
||||
*
|
||||
* @param ctx - @zh 事务上下文 @en Transaction context
|
||||
*/
|
||||
compensate(ctx: ITransactionContext): Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 事务上下文接口 | Transaction Context Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* @zh 事务上下文接口
|
||||
* @en Transaction context interface
|
||||
*/
|
||||
export interface ITransactionContext {
|
||||
/**
|
||||
* @zh 事务 ID
|
||||
* @en Transaction ID
|
||||
*/
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
* @zh 事务状态
|
||||
* @en Transaction state
|
||||
*/
|
||||
readonly state: TransactionState
|
||||
|
||||
/**
|
||||
* @zh 超时时间(毫秒)
|
||||
* @en Timeout in milliseconds
|
||||
*/
|
||||
readonly timeout: number
|
||||
|
||||
/**
|
||||
* @zh 操作列表
|
||||
* @en Operations
|
||||
*/
|
||||
readonly operations: ReadonlyArray<ITransactionOperation>
|
||||
|
||||
/**
|
||||
* @zh 存储实例
|
||||
* @en Storage instance
|
||||
*/
|
||||
readonly storage: ITransactionStorage | null
|
||||
|
||||
/**
|
||||
* @zh 元数据
|
||||
* @en Metadata
|
||||
*/
|
||||
readonly metadata: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* @zh 添加操作
|
||||
* @en Add operation
|
||||
*/
|
||||
addOperation<T extends ITransactionOperation>(operation: T): this
|
||||
|
||||
/**
|
||||
* @zh 执行事务
|
||||
* @en Execute transaction
|
||||
*/
|
||||
execute<T = unknown>(): Promise<TransactionResult<T>>
|
||||
|
||||
/**
|
||||
* @zh 回滚事务
|
||||
* @en Rollback transaction
|
||||
*/
|
||||
rollback(): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 获取上下文数据
|
||||
* @en Get context data
|
||||
*/
|
||||
get<T>(key: string): T | undefined
|
||||
|
||||
/**
|
||||
* @zh 设置上下文数据
|
||||
* @en Set context data
|
||||
*/
|
||||
set<T>(key: string, value: T): void
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @zh Saga 编排器
|
||||
* @en Saga Orchestrator
|
||||
*
|
||||
* @zh 实现分布式事务的 Saga 模式编排
|
||||
* @en Implements Saga pattern orchestration for distributed transactions
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationResult,
|
||||
} from '../core/types.js'
|
||||
|
||||
/**
|
||||
* @zh Saga 步骤状态
|
||||
* @en Saga step state
|
||||
*/
|
||||
export type SagaStepState = 'pending' | 'executing' | 'completed' | 'compensating' | 'compensated' | 'failed'
|
||||
|
||||
/**
|
||||
* @zh Saga 步骤
|
||||
* @en Saga step
|
||||
*/
|
||||
export interface SagaStep<T = unknown> {
|
||||
/**
|
||||
* @zh 步骤名称
|
||||
* @en Step name
|
||||
*/
|
||||
name: string
|
||||
|
||||
/**
|
||||
* @zh 目标服务器 ID(分布式用)
|
||||
* @en Target server ID (for distributed)
|
||||
*/
|
||||
serverId?: string
|
||||
|
||||
/**
|
||||
* @zh 执行函数
|
||||
* @en Execute function
|
||||
*/
|
||||
execute: (data: T) => Promise<OperationResult>
|
||||
|
||||
/**
|
||||
* @zh 补偿函数
|
||||
* @en Compensate function
|
||||
*/
|
||||
compensate: (data: T) => Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 步骤数据
|
||||
* @en Step data
|
||||
*/
|
||||
data: T
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Saga 步骤日志
|
||||
* @en Saga step log
|
||||
*/
|
||||
export interface SagaStepLog {
|
||||
name: string
|
||||
serverId?: string
|
||||
state: SagaStepState
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Saga 日志
|
||||
* @en Saga log
|
||||
*/
|
||||
export interface SagaLog {
|
||||
id: string
|
||||
state: 'pending' | 'running' | 'completed' | 'compensating' | 'compensated' | 'failed'
|
||||
steps: SagaStepLog[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Saga 结果
|
||||
* @en Saga result
|
||||
*/
|
||||
export interface SagaResult {
|
||||
success: boolean
|
||||
sagaId: string
|
||||
completedSteps: string[]
|
||||
failedStep?: string
|
||||
error?: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Saga 编排器配置
|
||||
* @en Saga orchestrator configuration
|
||||
*/
|
||||
export interface SagaOrchestratorConfig {
|
||||
/**
|
||||
* @zh 存储实例
|
||||
* @en Storage instance
|
||||
*/
|
||||
storage?: ITransactionStorage
|
||||
|
||||
/**
|
||||
* @zh 默认超时时间(毫秒)
|
||||
* @en Default timeout in milliseconds
|
||||
*/
|
||||
timeout?: number
|
||||
|
||||
/**
|
||||
* @zh 服务器 ID
|
||||
* @en Server ID
|
||||
*/
|
||||
serverId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成 Saga ID
|
||||
* @en Generate Saga ID
|
||||
*/
|
||||
function generateSagaId(): string {
|
||||
return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Saga 编排器
|
||||
* @en Saga Orchestrator
|
||||
*
|
||||
* @zh 管理分布式事务的 Saga 模式执行流程
|
||||
* @en Manages Saga pattern execution flow for distributed transactions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const orchestrator = new SagaOrchestrator({
|
||||
* storage: redisStorage,
|
||||
* serverId: 'server1',
|
||||
* })
|
||||
*
|
||||
* const result = await orchestrator.execute([
|
||||
* {
|
||||
* name: 'deduct_currency',
|
||||
* serverId: 'server1',
|
||||
* execute: async (data) => {
|
||||
* // 扣除货币
|
||||
* return { success: true }
|
||||
* },
|
||||
* compensate: async (data) => {
|
||||
* // 恢复货币
|
||||
* },
|
||||
* data: { playerId: '1', amount: 100 },
|
||||
* },
|
||||
* {
|
||||
* name: 'add_item',
|
||||
* serverId: 'server2',
|
||||
* execute: async (data) => {
|
||||
* // 添加物品
|
||||
* return { success: true }
|
||||
* },
|
||||
* compensate: async (data) => {
|
||||
* // 移除物品
|
||||
* },
|
||||
* data: { playerId: '1', itemId: 'sword' },
|
||||
* },
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export class SagaOrchestrator {
|
||||
private _storage: ITransactionStorage | null
|
||||
private _timeout: number
|
||||
private _serverId: string
|
||||
|
||||
constructor(config: SagaOrchestratorConfig = {}) {
|
||||
this._storage = config.storage ?? null
|
||||
this._timeout = config.timeout ?? 30000
|
||||
this._serverId = config.serverId ?? 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 执行 Saga
|
||||
* @en Execute Saga
|
||||
*/
|
||||
async execute<T>(steps: SagaStep<T>[]): Promise<SagaResult> {
|
||||
const sagaId = generateSagaId()
|
||||
const startTime = Date.now()
|
||||
const completedSteps: string[] = []
|
||||
|
||||
const sagaLog: SagaLog = {
|
||||
id: sagaId,
|
||||
state: 'pending',
|
||||
steps: steps.map((s) => ({
|
||||
name: s.name,
|
||||
serverId: s.serverId,
|
||||
state: 'pending' as SagaStepState,
|
||||
})),
|
||||
createdAt: startTime,
|
||||
updatedAt: startTime,
|
||||
metadata: { orchestratorServerId: this._serverId },
|
||||
}
|
||||
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
try {
|
||||
sagaLog.state = 'running'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
|
||||
if (Date.now() - startTime > this._timeout) {
|
||||
throw new Error('Saga execution timed out')
|
||||
}
|
||||
|
||||
sagaLog.steps[i].state = 'executing'
|
||||
sagaLog.steps[i].startedAt = Date.now()
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
const result = await step.execute(step.data)
|
||||
|
||||
if (!result.success) {
|
||||
sagaLog.steps[i].state = 'failed'
|
||||
sagaLog.steps[i].error = result.error
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
throw new Error(result.error ?? `Step ${step.name} failed`)
|
||||
}
|
||||
|
||||
sagaLog.steps[i].state = 'completed'
|
||||
sagaLog.steps[i].completedAt = Date.now()
|
||||
completedSteps.push(step.name)
|
||||
await this._saveSagaLog(sagaLog)
|
||||
}
|
||||
|
||||
sagaLog.state = 'completed'
|
||||
sagaLog.updatedAt = Date.now()
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sagaId,
|
||||
completedSteps,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failedStepIndex = completedSteps.length
|
||||
|
||||
sagaLog.state = 'compensating'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
for (let i = completedSteps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i]
|
||||
|
||||
sagaLog.steps[i].state = 'compensating'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
try {
|
||||
await step.compensate(step.data)
|
||||
sagaLog.steps[i].state = 'compensated'
|
||||
} catch (compError) {
|
||||
const compErrorMessage = compError instanceof Error ? compError.message : String(compError)
|
||||
sagaLog.steps[i].state = 'failed'
|
||||
sagaLog.steps[i].error = `Compensation failed: ${compErrorMessage}`
|
||||
}
|
||||
|
||||
await this._saveSagaLog(sagaLog)
|
||||
}
|
||||
|
||||
sagaLog.state = 'compensated'
|
||||
sagaLog.updatedAt = Date.now()
|
||||
await this._saveSagaLog(sagaLog)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
sagaId,
|
||||
completedSteps,
|
||||
failedStep: steps[failedStepIndex]?.name,
|
||||
error: errorMessage,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 恢复未完成的 Saga
|
||||
* @en Recover pending Sagas
|
||||
*/
|
||||
async recover(): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
|
||||
const pendingSagas = await this._getPendingSagas()
|
||||
let recoveredCount = 0
|
||||
|
||||
for (const saga of pendingSagas) {
|
||||
try {
|
||||
await this._recoverSaga(saga)
|
||||
recoveredCount++
|
||||
} catch (error) {
|
||||
console.error(`Failed to recover saga ${saga.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取 Saga 日志
|
||||
* @en Get Saga log
|
||||
*/
|
||||
async getSagaLog(sagaId: string): Promise<SagaLog | null> {
|
||||
if (!this._storage) return null
|
||||
return this._storage.get<SagaLog>(`saga:${sagaId}`)
|
||||
}
|
||||
|
||||
private async _saveSagaLog(log: SagaLog): Promise<void> {
|
||||
if (!this._storage) return
|
||||
log.updatedAt = Date.now()
|
||||
await this._storage.set(`saga:${log.id}`, log)
|
||||
}
|
||||
|
||||
private async _getPendingSagas(): Promise<SagaLog[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
private async _recoverSaga(saga: SagaLog): Promise<void> {
|
||||
if (saga.state === 'running' || saga.state === 'compensating') {
|
||||
const completedSteps = saga.steps
|
||||
.filter((s) => s.state === 'completed')
|
||||
.map((s) => s.name)
|
||||
|
||||
saga.state = 'compensated'
|
||||
saga.updatedAt = Date.now()
|
||||
|
||||
if (this._storage) {
|
||||
await this._storage.set(`saga:${saga.id}`, saga)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Saga 编排器
|
||||
* @en Create Saga orchestrator
|
||||
*/
|
||||
export function createSagaOrchestrator(config: SagaOrchestratorConfig = {}): SagaOrchestrator {
|
||||
return new SagaOrchestrator(config)
|
||||
}
|
||||
15
packages/framework/transaction/src/distributed/index.ts
Normal file
15
packages/framework/transaction/src/distributed/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @zh 分布式模块导出
|
||||
* @en Distributed module exports
|
||||
*/
|
||||
|
||||
export {
|
||||
SagaOrchestrator,
|
||||
createSagaOrchestrator,
|
||||
type SagaOrchestratorConfig,
|
||||
type SagaStep,
|
||||
type SagaStepState,
|
||||
type SagaStepLog,
|
||||
type SagaLog,
|
||||
type SagaResult,
|
||||
} from './SagaOrchestrator.js'
|
||||
165
packages/framework/transaction/src/index.ts
Normal file
165
packages/framework/transaction/src/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @zh @esengine/transaction 事务系统
|
||||
* @en @esengine/transaction Transaction System
|
||||
*
|
||||
* @zh 提供游戏事务处理能力,支持商店购买、玩家交易、分布式事务
|
||||
* @en Provides game transaction capabilities, supporting shop purchases, player trading, and distributed transactions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import {
|
||||
* TransactionManager,
|
||||
* MemoryStorage,
|
||||
* CurrencyOperation,
|
||||
* InventoryOperation,
|
||||
* } from '@esengine/transaction'
|
||||
*
|
||||
* // 创建事务管理器
|
||||
* const manager = new TransactionManager({
|
||||
* storage: new MemoryStorage(),
|
||||
* })
|
||||
*
|
||||
* // 执行事务
|
||||
* 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',
|
||||
* quantity: 1,
|
||||
* }))
|
||||
* })
|
||||
*
|
||||
* if (result.success) {
|
||||
* console.log('Transaction completed!')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Core | 核心
|
||||
// =============================================================================
|
||||
|
||||
export type {
|
||||
TransactionState,
|
||||
OperationResult,
|
||||
TransactionResult,
|
||||
OperationLog,
|
||||
TransactionLog,
|
||||
TransactionOptions,
|
||||
TransactionManagerConfig,
|
||||
ITransactionStorage,
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
} from './core/types.js'
|
||||
|
||||
export {
|
||||
TransactionContext,
|
||||
createTransactionContext,
|
||||
} from './core/TransactionContext.js'
|
||||
|
||||
export {
|
||||
TransactionManager,
|
||||
createTransactionManager,
|
||||
} from './core/TransactionManager.js'
|
||||
|
||||
// =============================================================================
|
||||
// Storage | 存储
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
MemoryStorage,
|
||||
createMemoryStorage,
|
||||
type MemoryStorageConfig,
|
||||
} from './storage/MemoryStorage.js'
|
||||
|
||||
export {
|
||||
RedisStorage,
|
||||
createRedisStorage,
|
||||
type RedisStorageConfig,
|
||||
type RedisClient,
|
||||
} from './storage/RedisStorage.js'
|
||||
|
||||
export {
|
||||
MongoStorage,
|
||||
createMongoStorage,
|
||||
type MongoStorageConfig,
|
||||
type MongoDb,
|
||||
type MongoCollection,
|
||||
} from './storage/MongoStorage.js'
|
||||
|
||||
// =============================================================================
|
||||
// Operations | 操作
|
||||
// =============================================================================
|
||||
|
||||
export { BaseOperation } from './operations/BaseOperation.js'
|
||||
|
||||
export {
|
||||
CurrencyOperation,
|
||||
createCurrencyOperation,
|
||||
type CurrencyOperationType,
|
||||
type CurrencyOperationData,
|
||||
type CurrencyOperationResult,
|
||||
type ICurrencyProvider,
|
||||
} from './operations/CurrencyOperation.js'
|
||||
|
||||
export {
|
||||
InventoryOperation,
|
||||
createInventoryOperation,
|
||||
type InventoryOperationType,
|
||||
type InventoryOperationData,
|
||||
type InventoryOperationResult,
|
||||
type IInventoryProvider,
|
||||
type ItemData,
|
||||
} from './operations/InventoryOperation.js'
|
||||
|
||||
export {
|
||||
TradeOperation,
|
||||
createTradeOperation,
|
||||
type TradeOperationData,
|
||||
type TradeOperationResult,
|
||||
type TradeItem,
|
||||
type TradeCurrency,
|
||||
type TradeParty,
|
||||
type ITradeProvider,
|
||||
} from './operations/TradeOperation.js'
|
||||
|
||||
// =============================================================================
|
||||
// Distributed | 分布式
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
SagaOrchestrator,
|
||||
createSagaOrchestrator,
|
||||
type SagaOrchestratorConfig,
|
||||
type SagaStep,
|
||||
type SagaStepState,
|
||||
type SagaStepLog,
|
||||
type SagaLog,
|
||||
type SagaResult,
|
||||
} from './distributed/SagaOrchestrator.js'
|
||||
|
||||
// =============================================================================
|
||||
// Integration | 集成
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
withTransactions,
|
||||
TransactionRoom,
|
||||
type TransactionRoomConfig,
|
||||
type ITransactionRoom,
|
||||
} from './integration/RoomTransactionMixin.js'
|
||||
|
||||
// =============================================================================
|
||||
// Tokens | 令牌
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
TransactionManagerToken,
|
||||
TransactionStorageToken,
|
||||
} from './tokens.js'
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @zh Room 事务扩展
|
||||
* @en Room transaction extension
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionStorage,
|
||||
ITransactionContext,
|
||||
TransactionOptions,
|
||||
TransactionResult,
|
||||
} from '../core/types.js'
|
||||
import { TransactionManager } from '../core/TransactionManager.js'
|
||||
|
||||
/**
|
||||
* @zh 事务 Room 配置
|
||||
* @en Transaction Room configuration
|
||||
*/
|
||||
export interface TransactionRoomConfig {
|
||||
/**
|
||||
* @zh 存储实例
|
||||
* @en Storage instance
|
||||
*/
|
||||
storage?: ITransactionStorage
|
||||
|
||||
/**
|
||||
* @zh 默认超时时间(毫秒)
|
||||
* @en Default timeout in milliseconds
|
||||
*/
|
||||
defaultTimeout?: number
|
||||
|
||||
/**
|
||||
* @zh 服务器 ID
|
||||
* @en Server ID
|
||||
*/
|
||||
serverId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务 Room 接口
|
||||
* @en Transaction Room interface
|
||||
*/
|
||||
export interface ITransactionRoom {
|
||||
/**
|
||||
* @zh 事务管理器
|
||||
* @en Transaction manager
|
||||
*/
|
||||
readonly transactions: TransactionManager
|
||||
|
||||
/**
|
||||
* @zh 开始事务
|
||||
* @en Begin transaction
|
||||
*/
|
||||
beginTransaction(options?: TransactionOptions): ITransactionContext
|
||||
|
||||
/**
|
||||
* @zh 执行事务
|
||||
* @en Run transaction
|
||||
*/
|
||||
runTransaction<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionResult<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建事务 Room mixin
|
||||
* @en Create transaction Room mixin
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room } from '@esengine/server'
|
||||
* import { withTransactions, RedisStorage } from '@esengine/transaction'
|
||||
*
|
||||
* class GameRoom extends withTransactions(Room, {
|
||||
* storage: new RedisStorage({ client: redisClient }),
|
||||
* }) {
|
||||
* async handleBuy(itemId: string, player: Player) {
|
||||
* const result = await this.runTransaction((tx) => {
|
||||
* tx.addOperation(new CurrencyOperation({
|
||||
* type: 'deduct',
|
||||
* playerId: player.id,
|
||||
* currency: 'gold',
|
||||
* amount: 100,
|
||||
* }))
|
||||
* })
|
||||
*
|
||||
* if (result.success) {
|
||||
* player.send('buy_success', { itemId })
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function withTransactions<TBase extends new (...args: any[]) => any>(
|
||||
Base: TBase,
|
||||
config: TransactionRoomConfig = {}
|
||||
): TBase & (new (...args: any[]) => ITransactionRoom) {
|
||||
return class TransactionRoom extends Base implements ITransactionRoom {
|
||||
private _transactionManager: TransactionManager
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args)
|
||||
this._transactionManager = new TransactionManager({
|
||||
storage: config.storage,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
serverId: config.serverId,
|
||||
})
|
||||
}
|
||||
|
||||
get transactions(): TransactionManager {
|
||||
return this._transactionManager
|
||||
}
|
||||
|
||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||
return this._transactionManager.begin(options)
|
||||
}
|
||||
|
||||
runTransaction<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionResult<T>> {
|
||||
return this._transactionManager.run<T>(builder, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 事务 Room 抽象基类
|
||||
* @en Transaction Room abstract base class
|
||||
*
|
||||
* @zh 可以直接继承使用,也可以使用 withTransactions mixin
|
||||
* @en Can be extended directly or use withTransactions mixin
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends TransactionRoom {
|
||||
* constructor() {
|
||||
* super({ storage: new RedisStorage({ client: redisClient }) })
|
||||
* }
|
||||
*
|
||||
* async handleTrade(data: TradeData, player: Player) {
|
||||
* const result = await this.runTransaction((tx) => {
|
||||
* // 添加交易操作
|
||||
* })
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class TransactionRoom implements ITransactionRoom {
|
||||
private _transactionManager: TransactionManager
|
||||
|
||||
constructor(config: TransactionRoomConfig = {}) {
|
||||
this._transactionManager = new TransactionManager({
|
||||
storage: config.storage,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
serverId: config.serverId,
|
||||
})
|
||||
}
|
||||
|
||||
get transactions(): TransactionManager {
|
||||
return this._transactionManager
|
||||
}
|
||||
|
||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||
return this._transactionManager.begin(options)
|
||||
}
|
||||
|
||||
runTransaction<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionResult<T>> {
|
||||
return this._transactionManager.run<T>(builder, options)
|
||||
}
|
||||
}
|
||||
11
packages/framework/transaction/src/integration/index.ts
Normal file
11
packages/framework/transaction/src/integration/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @zh 集成模块导出
|
||||
* @en Integration module exports
|
||||
*/
|
||||
|
||||
export {
|
||||
withTransactions,
|
||||
TransactionRoom,
|
||||
type TransactionRoomConfig,
|
||||
type ITransactionRoom,
|
||||
} from './RoomTransactionMixin.js'
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @zh 操作基类
|
||||
* @en Base operation class
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
OperationResult,
|
||||
} from '../core/types.js'
|
||||
|
||||
/**
|
||||
* @zh 操作基类
|
||||
* @en Base operation class
|
||||
*
|
||||
* @zh 提供通用的操作实现模板
|
||||
* @en Provides common operation implementation template
|
||||
*/
|
||||
export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||
implements ITransactionOperation<TData, TResult>
|
||||
{
|
||||
abstract readonly name: string
|
||||
readonly data: TData
|
||||
|
||||
constructor(data: TData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证前置条件(默认通过)
|
||||
* @en Validate preconditions (passes by default)
|
||||
*/
|
||||
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 执行操作
|
||||
* @en Execute operation
|
||||
*/
|
||||
abstract execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>
|
||||
|
||||
/**
|
||||
* @zh 补偿操作
|
||||
* @en Compensate operation
|
||||
*/
|
||||
abstract compensate(ctx: ITransactionContext): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 创建成功结果
|
||||
* @en Create success result
|
||||
*/
|
||||
protected success(data?: TResult): OperationResult<TResult> {
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建失败结果
|
||||
* @en Create failure result
|
||||
*/
|
||||
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
|
||||
return { success: false, error, errorCode }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @zh 货币操作
|
||||
* @en Currency operation
|
||||
*/
|
||||
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
||||
import { BaseOperation } from './BaseOperation.js'
|
||||
|
||||
/**
|
||||
* @zh 货币操作类型
|
||||
* @en Currency operation type
|
||||
*/
|
||||
export type CurrencyOperationType = 'add' | 'deduct'
|
||||
|
||||
/**
|
||||
* @zh 货币操作数据
|
||||
* @en Currency operation data
|
||||
*/
|
||||
export interface CurrencyOperationData {
|
||||
/**
|
||||
* @zh 操作类型
|
||||
* @en Operation type
|
||||
*/
|
||||
type: CurrencyOperationType
|
||||
|
||||
/**
|
||||
* @zh 玩家 ID
|
||||
* @en Player ID
|
||||
*/
|
||||
playerId: string
|
||||
|
||||
/**
|
||||
* @zh 货币类型(如 gold, diamond 等)
|
||||
* @en Currency type (e.g., gold, diamond)
|
||||
*/
|
||||
currency: string
|
||||
|
||||
/**
|
||||
* @zh 数量
|
||||
* @en Amount
|
||||
*/
|
||||
amount: number
|
||||
|
||||
/**
|
||||
* @zh 原因/来源
|
||||
* @en Reason/source
|
||||
*/
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 货币操作结果
|
||||
* @en Currency operation result
|
||||
*/
|
||||
export interface CurrencyOperationResult {
|
||||
/**
|
||||
* @zh 操作前余额
|
||||
* @en Balance before operation
|
||||
*/
|
||||
beforeBalance: number
|
||||
|
||||
/**
|
||||
* @zh 操作后余额
|
||||
* @en Balance after operation
|
||||
*/
|
||||
afterBalance: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 货币数据提供者接口
|
||||
* @en Currency data provider interface
|
||||
*/
|
||||
export interface ICurrencyProvider {
|
||||
/**
|
||||
* @zh 获取货币余额
|
||||
* @en Get currency balance
|
||||
*/
|
||||
getBalance(playerId: string, currency: string): Promise<number>
|
||||
|
||||
/**
|
||||
* @zh 设置货币余额
|
||||
* @en Set currency balance
|
||||
*/
|
||||
setBalance(playerId: string, currency: string, amount: number): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 货币操作
|
||||
* @en Currency operation
|
||||
*
|
||||
* @zh 用于处理货币的增加和扣除
|
||||
* @en Used for handling currency addition and deduction
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 扣除金币
|
||||
* tx.addOperation(new CurrencyOperation({
|
||||
* type: 'deduct',
|
||||
* playerId: 'player1',
|
||||
* currency: 'gold',
|
||||
* amount: 100,
|
||||
* reason: 'purchase_item',
|
||||
* }))
|
||||
*
|
||||
* // 增加钻石
|
||||
* tx.addOperation(new CurrencyOperation({
|
||||
* type: 'add',
|
||||
* playerId: 'player1',
|
||||
* currency: 'diamond',
|
||||
* amount: 50,
|
||||
* }))
|
||||
* ```
|
||||
*/
|
||||
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
|
||||
readonly name = 'currency'
|
||||
|
||||
private _provider: ICurrencyProvider | null = null
|
||||
private _beforeBalance: number = 0
|
||||
|
||||
/**
|
||||
* @zh 设置货币数据提供者
|
||||
* @en Set currency data provider
|
||||
*/
|
||||
setProvider(provider: ICurrencyProvider): this {
|
||||
this._provider = provider
|
||||
return this
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
if (this.data.amount <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.data.type === 'deduct') {
|
||||
const balance = await this._getBalance(ctx)
|
||||
return balance >= this.data.amount
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
|
||||
const { type, playerId, currency, amount } = this.data
|
||||
|
||||
this._beforeBalance = await this._getBalance(ctx)
|
||||
|
||||
let afterBalance: number
|
||||
|
||||
if (type === 'add') {
|
||||
afterBalance = this._beforeBalance + amount
|
||||
} else {
|
||||
if (this._beforeBalance < amount) {
|
||||
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE')
|
||||
}
|
||||
afterBalance = this._beforeBalance - amount
|
||||
}
|
||||
|
||||
await this._setBalance(ctx, afterBalance)
|
||||
|
||||
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance)
|
||||
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance)
|
||||
|
||||
return this.success({
|
||||
beforeBalance: this._beforeBalance,
|
||||
afterBalance,
|
||||
})
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
await this._setBalance(ctx, this._beforeBalance)
|
||||
}
|
||||
|
||||
private async _getBalance(ctx: ITransactionContext): Promise<number> {
|
||||
const { playerId, currency } = this.data
|
||||
|
||||
if (this._provider) {
|
||||
return this._provider.getBalance(playerId, currency)
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`)
|
||||
return balance ?? 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
|
||||
const { playerId, currency } = this.data
|
||||
|
||||
if (this._provider) {
|
||||
await this._provider.setBalance(playerId, currency, amount)
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建货币操作
|
||||
* @en Create currency operation
|
||||
*/
|
||||
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
|
||||
return new CurrencyOperation(data)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @zh 背包操作
|
||||
* @en Inventory operation
|
||||
*/
|
||||
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
||||
import { BaseOperation } from './BaseOperation.js'
|
||||
|
||||
/**
|
||||
* @zh 背包操作类型
|
||||
* @en Inventory operation type
|
||||
*/
|
||||
export type InventoryOperationType = 'add' | 'remove' | 'update'
|
||||
|
||||
/**
|
||||
* @zh 物品数据
|
||||
* @en Item data
|
||||
*/
|
||||
export interface ItemData {
|
||||
/**
|
||||
* @zh 物品 ID
|
||||
* @en Item ID
|
||||
*/
|
||||
itemId: string
|
||||
|
||||
/**
|
||||
* @zh 数量
|
||||
* @en Quantity
|
||||
*/
|
||||
quantity: number
|
||||
|
||||
/**
|
||||
* @zh 物品属性
|
||||
* @en Item properties
|
||||
*/
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 背包操作数据
|
||||
* @en Inventory operation data
|
||||
*/
|
||||
export interface InventoryOperationData {
|
||||
/**
|
||||
* @zh 操作类型
|
||||
* @en Operation type
|
||||
*/
|
||||
type: InventoryOperationType
|
||||
|
||||
/**
|
||||
* @zh 玩家 ID
|
||||
* @en Player ID
|
||||
*/
|
||||
playerId: string
|
||||
|
||||
/**
|
||||
* @zh 物品 ID
|
||||
* @en Item ID
|
||||
*/
|
||||
itemId: string
|
||||
|
||||
/**
|
||||
* @zh 数量
|
||||
* @en Quantity
|
||||
*/
|
||||
quantity: number
|
||||
|
||||
/**
|
||||
* @zh 物品属性(用于更新)
|
||||
* @en Item properties (for update)
|
||||
*/
|
||||
properties?: Record<string, unknown>
|
||||
|
||||
/**
|
||||
* @zh 原因/来源
|
||||
* @en Reason/source
|
||||
*/
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 背包操作结果
|
||||
* @en Inventory operation result
|
||||
*/
|
||||
export interface InventoryOperationResult {
|
||||
/**
|
||||
* @zh 操作前的物品数据
|
||||
* @en Item data before operation
|
||||
*/
|
||||
beforeItem?: ItemData
|
||||
|
||||
/**
|
||||
* @zh 操作后的物品数据
|
||||
* @en Item data after operation
|
||||
*/
|
||||
afterItem?: ItemData
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 背包数据提供者接口
|
||||
* @en Inventory data provider interface
|
||||
*/
|
||||
export interface IInventoryProvider {
|
||||
/**
|
||||
* @zh 获取物品
|
||||
* @en Get item
|
||||
*/
|
||||
getItem(playerId: string, itemId: string): Promise<ItemData | null>
|
||||
|
||||
/**
|
||||
* @zh 设置物品
|
||||
* @en Set item
|
||||
*/
|
||||
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 检查背包容量
|
||||
* @en Check inventory capacity
|
||||
*/
|
||||
hasCapacity?(playerId: string, count: number): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 背包操作
|
||||
* @en Inventory operation
|
||||
*
|
||||
* @zh 用于处理物品的添加、移除和更新
|
||||
* @en Used for handling item addition, removal, and update
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 添加物品
|
||||
* tx.addOperation(new InventoryOperation({
|
||||
* type: 'add',
|
||||
* playerId: 'player1',
|
||||
* itemId: 'sword_001',
|
||||
* quantity: 1,
|
||||
* }))
|
||||
*
|
||||
* // 移除物品
|
||||
* tx.addOperation(new InventoryOperation({
|
||||
* type: 'remove',
|
||||
* playerId: 'player1',
|
||||
* itemId: 'potion_hp',
|
||||
* quantity: 5,
|
||||
* }))
|
||||
* ```
|
||||
*/
|
||||
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
|
||||
readonly name = 'inventory'
|
||||
|
||||
private _provider: IInventoryProvider | null = null
|
||||
private _beforeItem: ItemData | null = null
|
||||
|
||||
/**
|
||||
* @zh 设置背包数据提供者
|
||||
* @en Set inventory data provider
|
||||
*/
|
||||
setProvider(provider: IInventoryProvider): this {
|
||||
this._provider = provider
|
||||
return this
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
const { type, quantity } = this.data
|
||||
|
||||
if (quantity <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (type === 'remove') {
|
||||
const item = await this._getItem(ctx)
|
||||
return item !== null && item.quantity >= quantity
|
||||
}
|
||||
|
||||
if (type === 'add' && this._provider?.hasCapacity) {
|
||||
return this._provider.hasCapacity(this.data.playerId, 1)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
|
||||
const { type, playerId, itemId, quantity, properties } = this.data
|
||||
|
||||
this._beforeItem = await this._getItem(ctx)
|
||||
|
||||
let afterItem: ItemData | null = null
|
||||
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
if (this._beforeItem) {
|
||||
afterItem = {
|
||||
...this._beforeItem,
|
||||
quantity: this._beforeItem.quantity + quantity,
|
||||
}
|
||||
} else {
|
||||
afterItem = {
|
||||
itemId,
|
||||
quantity,
|
||||
properties,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
|
||||
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM')
|
||||
}
|
||||
|
||||
const newQuantity = this._beforeItem.quantity - quantity
|
||||
if (newQuantity > 0) {
|
||||
afterItem = {
|
||||
...this._beforeItem,
|
||||
quantity: newQuantity,
|
||||
}
|
||||
} else {
|
||||
afterItem = null
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
if (!this._beforeItem) {
|
||||
return this.failure('Item not found', 'ITEM_NOT_FOUND')
|
||||
}
|
||||
|
||||
afterItem = {
|
||||
...this._beforeItem,
|
||||
quantity: quantity > 0 ? quantity : this._beforeItem.quantity,
|
||||
properties: properties ?? this._beforeItem.properties,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await this._setItem(ctx, afterItem)
|
||||
|
||||
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem)
|
||||
ctx.set(`inventory:${playerId}:${itemId}:after`, afterItem)
|
||||
|
||||
return this.success({
|
||||
beforeItem: this._beforeItem ?? undefined,
|
||||
afterItem: afterItem ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
await this._setItem(ctx, this._beforeItem)
|
||||
}
|
||||
|
||||
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
|
||||
const { playerId, itemId } = this.data
|
||||
|
||||
if (this._provider) {
|
||||
return this._provider.getItem(playerId, itemId)
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
|
||||
const { playerId, itemId } = this.data
|
||||
|
||||
if (this._provider) {
|
||||
await this._provider.setItem(playerId, itemId, item)
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
if (item) {
|
||||
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item)
|
||||
} else {
|
||||
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建背包操作
|
||||
* @en Create inventory operation
|
||||
*/
|
||||
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
|
||||
return new InventoryOperation(data)
|
||||
}
|
||||
331
packages/framework/transaction/src/operations/TradeOperation.ts
Normal file
331
packages/framework/transaction/src/operations/TradeOperation.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @zh 交易操作
|
||||
* @en Trade operation
|
||||
*/
|
||||
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
||||
import { BaseOperation } from './BaseOperation.js'
|
||||
import { CurrencyOperation, type CurrencyOperationData, type ICurrencyProvider } from './CurrencyOperation.js'
|
||||
import { InventoryOperation, type InventoryOperationData, type IInventoryProvider, type ItemData } from './InventoryOperation.js'
|
||||
|
||||
/**
|
||||
* @zh 交易物品
|
||||
* @en Trade item
|
||||
*/
|
||||
export interface TradeItem {
|
||||
/**
|
||||
* @zh 物品 ID
|
||||
* @en Item ID
|
||||
*/
|
||||
itemId: string
|
||||
|
||||
/**
|
||||
* @zh 数量
|
||||
* @en Quantity
|
||||
*/
|
||||
quantity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易货币
|
||||
* @en Trade currency
|
||||
*/
|
||||
export interface TradeCurrency {
|
||||
/**
|
||||
* @zh 货币类型
|
||||
* @en Currency type
|
||||
*/
|
||||
currency: string
|
||||
|
||||
/**
|
||||
* @zh 数量
|
||||
* @en Amount
|
||||
*/
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易方数据
|
||||
* @en Trade party data
|
||||
*/
|
||||
export interface TradeParty {
|
||||
/**
|
||||
* @zh 玩家 ID
|
||||
* @en Player ID
|
||||
*/
|
||||
playerId: string
|
||||
|
||||
/**
|
||||
* @zh 给出的物品
|
||||
* @en Items to give
|
||||
*/
|
||||
items?: TradeItem[]
|
||||
|
||||
/**
|
||||
* @zh 给出的货币
|
||||
* @en Currencies to give
|
||||
*/
|
||||
currencies?: TradeCurrency[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易操作数据
|
||||
* @en Trade operation data
|
||||
*/
|
||||
export interface TradeOperationData {
|
||||
/**
|
||||
* @zh 交易 ID
|
||||
* @en Trade ID
|
||||
*/
|
||||
tradeId: string
|
||||
|
||||
/**
|
||||
* @zh 交易发起方
|
||||
* @en Trade initiator
|
||||
*/
|
||||
partyA: TradeParty
|
||||
|
||||
/**
|
||||
* @zh 交易接收方
|
||||
* @en Trade receiver
|
||||
*/
|
||||
partyB: TradeParty
|
||||
|
||||
/**
|
||||
* @zh 原因/备注
|
||||
* @en Reason/note
|
||||
*/
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易操作结果
|
||||
* @en Trade operation result
|
||||
*/
|
||||
export interface TradeOperationResult {
|
||||
/**
|
||||
* @zh 交易 ID
|
||||
* @en Trade ID
|
||||
*/
|
||||
tradeId: string
|
||||
|
||||
/**
|
||||
* @zh 交易是否成功
|
||||
* @en Whether trade succeeded
|
||||
*/
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易数据提供者
|
||||
* @en Trade data provider
|
||||
*/
|
||||
export interface ITradeProvider {
|
||||
currencyProvider?: ICurrencyProvider
|
||||
inventoryProvider?: IInventoryProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 交易操作
|
||||
* @en Trade operation
|
||||
*
|
||||
* @zh 用于处理玩家之间的物品和货币交换
|
||||
* @en Used for handling item and currency exchange between players
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* tx.addOperation(new TradeOperation({
|
||||
* tradeId: 'trade_001',
|
||||
* partyA: {
|
||||
* playerId: 'player1',
|
||||
* items: [{ itemId: 'sword', quantity: 1 }],
|
||||
* },
|
||||
* partyB: {
|
||||
* playerId: 'player2',
|
||||
* currencies: [{ currency: 'gold', amount: 1000 }],
|
||||
* },
|
||||
* }))
|
||||
* ```
|
||||
*/
|
||||
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
|
||||
readonly name = 'trade'
|
||||
|
||||
private _provider: ITradeProvider | null = null
|
||||
private _subOperations: (CurrencyOperation | InventoryOperation)[] = []
|
||||
private _executedCount = 0
|
||||
|
||||
/**
|
||||
* @zh 设置交易数据提供者
|
||||
* @en Set trade data provider
|
||||
*/
|
||||
setProvider(provider: ITradeProvider): this {
|
||||
this._provider = provider
|
||||
return this
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
this._buildSubOperations()
|
||||
|
||||
for (const op of this._subOperations) {
|
||||
const isValid = await op.validate(ctx)
|
||||
if (!isValid) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
|
||||
this._buildSubOperations()
|
||||
this._executedCount = 0
|
||||
|
||||
try {
|
||||
for (const op of this._subOperations) {
|
||||
const result = await op.execute(ctx)
|
||||
if (!result.success) {
|
||||
await this._compensateExecuted(ctx)
|
||||
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED')
|
||||
}
|
||||
this._executedCount++
|
||||
}
|
||||
|
||||
return this.success({
|
||||
tradeId: this.data.tradeId,
|
||||
completed: true,
|
||||
})
|
||||
} catch (error) {
|
||||
await this._compensateExecuted(ctx)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return this.failure(errorMessage, 'TRADE_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
await this._compensateExecuted(ctx)
|
||||
}
|
||||
|
||||
private _buildSubOperations(): void {
|
||||
if (this._subOperations.length > 0) return
|
||||
|
||||
const { partyA, partyB } = this.data
|
||||
|
||||
if (partyA.items) {
|
||||
for (const item of partyA.items) {
|
||||
const removeOp = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: partyA.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
const addOp = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: partyB.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:receive`,
|
||||
})
|
||||
|
||||
if (this._provider?.inventoryProvider) {
|
||||
removeOp.setProvider(this._provider.inventoryProvider)
|
||||
addOp.setProvider(this._provider.inventoryProvider)
|
||||
}
|
||||
|
||||
this._subOperations.push(removeOp, addOp)
|
||||
}
|
||||
}
|
||||
|
||||
if (partyA.currencies) {
|
||||
for (const curr of partyA.currencies) {
|
||||
const deductOp = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: partyA.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
const addOp = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: partyB.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:receive`,
|
||||
})
|
||||
|
||||
if (this._provider?.currencyProvider) {
|
||||
deductOp.setProvider(this._provider.currencyProvider)
|
||||
addOp.setProvider(this._provider.currencyProvider)
|
||||
}
|
||||
|
||||
this._subOperations.push(deductOp, addOp)
|
||||
}
|
||||
}
|
||||
|
||||
if (partyB.items) {
|
||||
for (const item of partyB.items) {
|
||||
const removeOp = new InventoryOperation({
|
||||
type: 'remove',
|
||||
playerId: partyB.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
const addOp = new InventoryOperation({
|
||||
type: 'add',
|
||||
playerId: partyA.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:receive`,
|
||||
})
|
||||
|
||||
if (this._provider?.inventoryProvider) {
|
||||
removeOp.setProvider(this._provider.inventoryProvider)
|
||||
addOp.setProvider(this._provider.inventoryProvider)
|
||||
}
|
||||
|
||||
this._subOperations.push(removeOp, addOp)
|
||||
}
|
||||
}
|
||||
|
||||
if (partyB.currencies) {
|
||||
for (const curr of partyB.currencies) {
|
||||
const deductOp = new CurrencyOperation({
|
||||
type: 'deduct',
|
||||
playerId: partyB.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
const addOp = new CurrencyOperation({
|
||||
type: 'add',
|
||||
playerId: partyA.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:receive`,
|
||||
})
|
||||
|
||||
if (this._provider?.currencyProvider) {
|
||||
deductOp.setProvider(this._provider.currencyProvider)
|
||||
addOp.setProvider(this._provider.currencyProvider)
|
||||
}
|
||||
|
||||
this._subOperations.push(deductOp, addOp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _compensateExecuted(ctx: ITransactionContext): Promise<void> {
|
||||
for (let i = this._executedCount - 1; i >= 0; i--) {
|
||||
await this._subOperations[i].compensate(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建交易操作
|
||||
* @en Create trade operation
|
||||
*/
|
||||
export function createTradeOperation(data: TradeOperationData): TradeOperation {
|
||||
return new TradeOperation(data)
|
||||
}
|
||||
36
packages/framework/transaction/src/operations/index.ts
Normal file
36
packages/framework/transaction/src/operations/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @zh 操作模块导出
|
||||
* @en Operations module exports
|
||||
*/
|
||||
|
||||
export { BaseOperation } from './BaseOperation.js'
|
||||
|
||||
export {
|
||||
CurrencyOperation,
|
||||
createCurrencyOperation,
|
||||
type CurrencyOperationType,
|
||||
type CurrencyOperationData,
|
||||
type CurrencyOperationResult,
|
||||
type ICurrencyProvider,
|
||||
} from './CurrencyOperation.js'
|
||||
|
||||
export {
|
||||
InventoryOperation,
|
||||
createInventoryOperation,
|
||||
type InventoryOperationType,
|
||||
type InventoryOperationData,
|
||||
type InventoryOperationResult,
|
||||
type IInventoryProvider,
|
||||
type ItemData,
|
||||
} from './InventoryOperation.js'
|
||||
|
||||
export {
|
||||
TradeOperation,
|
||||
createTradeOperation,
|
||||
type TradeOperationData,
|
||||
type TradeOperationResult,
|
||||
type TradeItem,
|
||||
type TradeCurrency,
|
||||
type TradeParty,
|
||||
type ITradeProvider,
|
||||
} from './TradeOperation.js'
|
||||
229
packages/framework/transaction/src/storage/MemoryStorage.ts
Normal file
229
packages/framework/transaction/src/storage/MemoryStorage.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @zh 内存存储实现
|
||||
* @en Memory storage implementation
|
||||
*
|
||||
* @zh 用于开发和测试环境,不支持分布式
|
||||
* @en For development and testing, does not support distributed scenarios
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
|
||||
/**
|
||||
* @zh 内存存储配置
|
||||
* @en Memory storage configuration
|
||||
*/
|
||||
export interface MemoryStorageConfig {
|
||||
/**
|
||||
* @zh 最大事务日志数量
|
||||
* @en Maximum transaction log count
|
||||
*/
|
||||
maxTransactions?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 内存存储
|
||||
* @en Memory storage
|
||||
*
|
||||
* @zh 适用于单机开发和测试,数据仅保存在内存中
|
||||
* @en Suitable for single-machine development and testing, data is stored in memory only
|
||||
*/
|
||||
export class MemoryStorage implements ITransactionStorage {
|
||||
private _transactions: Map<string, TransactionLog> = new Map()
|
||||
private _data: Map<string, { value: unknown; expireAt?: number }> = new Map()
|
||||
private _locks: Map<string, { token: string; expireAt: number }> = new Map()
|
||||
private _maxTransactions: number
|
||||
|
||||
constructor(config: MemoryStorageConfig = {}) {
|
||||
this._maxTransactions = config.maxTransactions ?? 1000
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 分布式锁 | Distributed Lock
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
this._cleanExpiredLocks()
|
||||
|
||||
const existing = this._locks.get(key)
|
||||
if (existing && existing.expireAt > Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
this._locks.set(key, {
|
||||
token,
|
||||
expireAt: Date.now() + ttl,
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const lock = this._locks.get(key)
|
||||
if (!lock || lock.token !== token) {
|
||||
return false
|
||||
}
|
||||
|
||||
this._locks.delete(key)
|
||||
return true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 事务日志 | Transaction Log
|
||||
// =========================================================================
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
if (this._transactions.size >= this._maxTransactions) {
|
||||
this._cleanOldTransactions()
|
||||
}
|
||||
|
||||
this._transactions.set(tx.id, { ...tx })
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const tx = this._transactions.get(id)
|
||||
return tx ? { ...tx } : null
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const tx = this._transactions.get(id)
|
||||
if (tx) {
|
||||
tx.state = state
|
||||
tx.updatedAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async updateOperationState(
|
||||
transactionId: string,
|
||||
operationIndex: number,
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const tx = this._transactions.get(transactionId)
|
||||
if (tx && tx.operations[operationIndex]) {
|
||||
tx.operations[operationIndex].state = state
|
||||
if (error) {
|
||||
tx.operations[operationIndex].error = error
|
||||
}
|
||||
if (state === 'executed') {
|
||||
tx.operations[operationIndex].executedAt = Date.now()
|
||||
} else if (state === 'compensated') {
|
||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
||||
}
|
||||
tx.updatedAt = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const result: TransactionLog[] = []
|
||||
|
||||
for (const tx of this._transactions.values()) {
|
||||
if (tx.state === 'pending' || tx.state === 'executing') {
|
||||
if (!serverId || tx.metadata?.serverId === serverId) {
|
||||
result.push({ ...tx })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
this._transactions.delete(id)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 数据操作 | Data Operations
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
this._cleanExpiredData()
|
||||
|
||||
const entry = this._data.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
if (entry.expireAt && entry.expireAt < Date.now()) {
|
||||
this._data.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
this._data.set(key, {
|
||||
value,
|
||||
expireAt: ttl ? Date.now() + ttl : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this._data.delete(key)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 辅助方法 | Helper methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 清空所有数据(测试用)
|
||||
* @en Clear all data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this._transactions.clear()
|
||||
this._data.clear()
|
||||
this._locks.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取事务数量
|
||||
* @en Get transaction count
|
||||
*/
|
||||
get transactionCount(): number {
|
||||
return this._transactions.size
|
||||
}
|
||||
|
||||
private _cleanExpiredLocks(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, lock] of this._locks) {
|
||||
if (lock.expireAt < now) {
|
||||
this._locks.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanExpiredData(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of this._data) {
|
||||
if (entry.expireAt && entry.expireAt < now) {
|
||||
this._data.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanOldTransactions(): void {
|
||||
const sorted = Array.from(this._transactions.entries())
|
||||
.sort((a, b) => a[1].createdAt - b[1].createdAt)
|
||||
|
||||
const toRemove = sorted
|
||||
.slice(0, Math.floor(this._maxTransactions * 0.2))
|
||||
.filter(([_, tx]) => tx.state === 'committed' || tx.state === 'rolledback')
|
||||
|
||||
for (const [id] of toRemove) {
|
||||
this._transactions.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建内存存储
|
||||
* @en Create memory storage
|
||||
*/
|
||||
export function createMemoryStorage(config: MemoryStorageConfig = {}): MemoryStorage {
|
||||
return new MemoryStorage(config)
|
||||
}
|
||||
303
packages/framework/transaction/src/storage/MongoStorage.ts
Normal file
303
packages/framework/transaction/src/storage/MongoStorage.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @zh MongoDB 存储实现
|
||||
* @en MongoDB storage implementation
|
||||
*
|
||||
* @zh 支持持久化事务日志和查询
|
||||
* @en Supports persistent transaction logs and queries
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
|
||||
/**
|
||||
* @zh MongoDB Collection 接口
|
||||
* @en MongoDB Collection interface
|
||||
*/
|
||||
export interface MongoCollection<T> {
|
||||
findOne(filter: object): Promise<T | null>
|
||||
find(filter: object): {
|
||||
toArray(): Promise<T[]>
|
||||
}
|
||||
insertOne(doc: T): Promise<{ insertedId: unknown }>
|
||||
updateOne(filter: object, update: object): Promise<{ modifiedCount: number }>
|
||||
deleteOne(filter: object): Promise<{ deletedCount: number }>
|
||||
createIndex(spec: object, options?: object): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh MongoDB 数据库接口
|
||||
* @en MongoDB database interface
|
||||
*/
|
||||
export interface MongoDb {
|
||||
collection<T = unknown>(name: string): MongoCollection<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh MongoDB 存储配置
|
||||
* @en MongoDB storage configuration
|
||||
*/
|
||||
export interface MongoStorageConfig {
|
||||
/**
|
||||
* @zh MongoDB 数据库实例
|
||||
* @en MongoDB database instance
|
||||
*/
|
||||
db: MongoDb
|
||||
|
||||
/**
|
||||
* @zh 事务日志集合名称
|
||||
* @en Transaction log collection name
|
||||
*/
|
||||
transactionCollection?: string
|
||||
|
||||
/**
|
||||
* @zh 数据集合名称
|
||||
* @en Data collection name
|
||||
*/
|
||||
dataCollection?: string
|
||||
|
||||
/**
|
||||
* @zh 锁集合名称
|
||||
* @en Lock collection name
|
||||
*/
|
||||
lockCollection?: string
|
||||
}
|
||||
|
||||
interface LockDocument {
|
||||
_id: string
|
||||
token: string
|
||||
expireAt: Date
|
||||
}
|
||||
|
||||
interface DataDocument {
|
||||
_id: string
|
||||
value: unknown
|
||||
expireAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh MongoDB 存储
|
||||
* @en MongoDB storage
|
||||
*
|
||||
* @zh 基于 MongoDB 的事务存储,支持持久化和复杂查询
|
||||
* @en MongoDB-based transaction storage with persistence and complex query support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MongoClient } from 'mongodb'
|
||||
*
|
||||
* const client = new MongoClient('mongodb://localhost:27017')
|
||||
* await client.connect()
|
||||
* const db = client.db('game')
|
||||
*
|
||||
* const storage = new MongoStorage({ db })
|
||||
* await storage.ensureIndexes()
|
||||
* ```
|
||||
*/
|
||||
export class MongoStorage implements ITransactionStorage {
|
||||
private _db: MongoDb
|
||||
private _transactionCollection: string
|
||||
private _dataCollection: string
|
||||
private _lockCollection: string
|
||||
|
||||
constructor(config: MongoStorageConfig) {
|
||||
this._db = config.db
|
||||
this._transactionCollection = config.transactionCollection ?? 'transactions'
|
||||
this._dataCollection = config.dataCollection ?? 'transaction_data'
|
||||
this._lockCollection = config.lockCollection ?? 'transaction_locks'
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 确保索引存在
|
||||
* @en Ensure indexes exist
|
||||
*/
|
||||
async ensureIndexes(): Promise<void> {
|
||||
const txColl = this._db.collection<TransactionLog>(this._transactionCollection)
|
||||
await txColl.createIndex({ state: 1 })
|
||||
await txColl.createIndex({ 'metadata.serverId': 1 })
|
||||
await txColl.createIndex({ createdAt: 1 })
|
||||
|
||||
const lockColl = this._db.collection<LockDocument>(this._lockCollection)
|
||||
await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
||||
|
||||
const dataColl = this._db.collection<DataDocument>(this._dataCollection)
|
||||
await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 分布式锁 | Distributed Lock
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
const expireAt = new Date(Date.now() + ttl)
|
||||
|
||||
try {
|
||||
await coll.insertOne({
|
||||
_id: key,
|
||||
token,
|
||||
expireAt,
|
||||
})
|
||||
return token
|
||||
} catch (error) {
|
||||
const existing = await coll.findOne({ _id: key })
|
||||
if (existing && existing.expireAt < new Date()) {
|
||||
const result = await coll.updateOne(
|
||||
{ _id: key, expireAt: { $lt: new Date() } },
|
||||
{ $set: { token, expireAt } }
|
||||
)
|
||||
if (result.modifiedCount > 0) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
||||
const result = await coll.deleteOne({ _id: key, token })
|
||||
return result.deletedCount > 0
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 事务日志 | Transaction Log
|
||||
// =========================================================================
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
|
||||
const existing = await coll.findOne({ _id: tx.id })
|
||||
if (existing) {
|
||||
await coll.updateOne(
|
||||
{ _id: tx.id },
|
||||
{ $set: { ...tx, _id: tx.id } }
|
||||
)
|
||||
} else {
|
||||
await coll.insertOne({ ...tx, _id: tx.id })
|
||||
}
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
const doc = await coll.findOne({ _id: id })
|
||||
|
||||
if (!doc) return null
|
||||
|
||||
const { _id, ...tx } = doc
|
||||
return tx as TransactionLog
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
await coll.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { state, updatedAt: Date.now() } }
|
||||
)
|
||||
}
|
||||
|
||||
async updateOperationState(
|
||||
transactionId: string,
|
||||
operationIndex: number,
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
[`operations.${operationIndex}.state`]: state,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
if (error) {
|
||||
update[`operations.${operationIndex}.error`] = error
|
||||
}
|
||||
|
||||
if (state === 'executed') {
|
||||
update[`operations.${operationIndex}.executedAt`] = Date.now()
|
||||
} else if (state === 'compensated') {
|
||||
update[`operations.${operationIndex}.compensatedAt`] = Date.now()
|
||||
}
|
||||
|
||||
await coll.updateOne(
|
||||
{ _id: transactionId },
|
||||
{ $set: update }
|
||||
)
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
state: { $in: ['pending', 'executing'] },
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
filter['metadata.serverId'] = serverId
|
||||
}
|
||||
|
||||
const docs = await coll.find(filter).toArray()
|
||||
return docs.map(({ _id, ...tx }) => tx as TransactionLog)
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
await coll.deleteOne({ _id: id })
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 数据操作 | Data Operations
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
||||
const doc = await coll.findOne({ _id: key })
|
||||
|
||||
if (!doc) return null
|
||||
|
||||
if (doc.expireAt && doc.expireAt < new Date()) {
|
||||
await coll.deleteOne({ _id: key })
|
||||
return null
|
||||
}
|
||||
|
||||
return doc.value as T
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
||||
|
||||
const doc: DataDocument = {
|
||||
_id: key,
|
||||
value,
|
||||
}
|
||||
|
||||
if (ttl) {
|
||||
doc.expireAt = new Date(Date.now() + ttl)
|
||||
}
|
||||
|
||||
const existing = await coll.findOne({ _id: key })
|
||||
if (existing) {
|
||||
await coll.updateOne({ _id: key }, { $set: doc })
|
||||
} else {
|
||||
await coll.insertOne(doc)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const coll = this._db.collection(this._dataCollection)
|
||||
const result = await coll.deleteOne({ _id: key })
|
||||
return result.deletedCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 MongoDB 存储
|
||||
* @en Create MongoDB storage
|
||||
*/
|
||||
export function createMongoStorage(config: MongoStorageConfig): MongoStorage {
|
||||
return new MongoStorage(config)
|
||||
}
|
||||
244
packages/framework/transaction/src/storage/RedisStorage.ts
Normal file
244
packages/framework/transaction/src/storage/RedisStorage.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @zh Redis 存储实现
|
||||
* @en Redis storage implementation
|
||||
*
|
||||
* @zh 支持分布式锁和快速缓存
|
||||
* @en Supports distributed locking and fast caching
|
||||
*/
|
||||
|
||||
import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
|
||||
/**
|
||||
* @zh Redis 客户端接口(兼容 ioredis)
|
||||
* @en Redis client interface (compatible with ioredis)
|
||||
*/
|
||||
export interface RedisClient {
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string, ...args: string[]): Promise<string | null>
|
||||
del(...keys: string[]): Promise<number>
|
||||
eval(script: string, numkeys: number, ...args: (string | number)[]): Promise<unknown>
|
||||
hget(key: string, field: string): Promise<string | null>
|
||||
hset(key: string, ...args: (string | number)[]): Promise<number>
|
||||
hdel(key: string, ...fields: string[]): Promise<number>
|
||||
hgetall(key: string): Promise<Record<string, string>>
|
||||
keys(pattern: string): Promise<string[]>
|
||||
expire(key: string, seconds: number): Promise<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Redis 存储配置
|
||||
* @en Redis storage configuration
|
||||
*/
|
||||
export interface RedisStorageConfig {
|
||||
/**
|
||||
* @zh Redis 客户端实例
|
||||
* @en Redis client instance
|
||||
*/
|
||||
client: RedisClient
|
||||
|
||||
/**
|
||||
* @zh 键前缀
|
||||
* @en Key prefix
|
||||
*/
|
||||
prefix?: string
|
||||
|
||||
/**
|
||||
* @zh 事务日志过期时间(秒)
|
||||
* @en Transaction log expiration time in seconds
|
||||
*/
|
||||
transactionTTL?: number
|
||||
}
|
||||
|
||||
const LOCK_SCRIPT = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
/**
|
||||
* @zh Redis 存储
|
||||
* @en Redis storage
|
||||
*
|
||||
* @zh 基于 Redis 的分布式事务存储,支持分布式锁
|
||||
* @en Redis-based distributed transaction storage with distributed locking support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import Redis from 'ioredis'
|
||||
*
|
||||
* const redis = new Redis('redis://localhost:6379')
|
||||
* const storage = new RedisStorage({ client: redis })
|
||||
* ```
|
||||
*/
|
||||
export class RedisStorage implements ITransactionStorage {
|
||||
private _client: RedisClient
|
||||
private _prefix: string
|
||||
private _transactionTTL: number
|
||||
|
||||
constructor(config: RedisStorageConfig) {
|
||||
this._client = config.client
|
||||
this._prefix = config.prefix ?? 'tx:'
|
||||
this._transactionTTL = config.transactionTTL ?? 86400 // 24 hours
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 分布式锁 | Distributed Lock
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
const lockKey = `${this._prefix}lock:${key}`
|
||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
const ttlSeconds = Math.ceil(ttl / 1000)
|
||||
|
||||
const result = await this._client.set(lockKey, token, 'NX', 'EX', String(ttlSeconds))
|
||||
|
||||
return result === 'OK' ? token : null
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const lockKey = `${this._prefix}lock:${key}`
|
||||
|
||||
const result = await this._client.eval(LOCK_SCRIPT, 1, lockKey, token)
|
||||
return result === 1
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 事务日志 | Transaction Log
|
||||
// =========================================================================
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
const key = `${this._prefix}tx:${tx.id}`
|
||||
|
||||
await this._client.set(key, JSON.stringify(tx))
|
||||
await this._client.expire(key, this._transactionTTL)
|
||||
|
||||
if (tx.metadata?.serverId) {
|
||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`
|
||||
await this._client.hset(serverKey, tx.id, String(tx.createdAt))
|
||||
}
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const key = `${this._prefix}tx:${id}`
|
||||
const data = await this._client.get(key)
|
||||
|
||||
return data ? JSON.parse(data) : null
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const tx = await this.getTransaction(id)
|
||||
if (tx) {
|
||||
tx.state = state
|
||||
tx.updatedAt = Date.now()
|
||||
await this.saveTransaction(tx)
|
||||
}
|
||||
}
|
||||
|
||||
async updateOperationState(
|
||||
transactionId: string,
|
||||
operationIndex: number,
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const tx = await this.getTransaction(transactionId)
|
||||
if (tx && tx.operations[operationIndex]) {
|
||||
tx.operations[operationIndex].state = state
|
||||
if (error) {
|
||||
tx.operations[operationIndex].error = error
|
||||
}
|
||||
if (state === 'executed') {
|
||||
tx.operations[operationIndex].executedAt = Date.now()
|
||||
} else if (state === 'compensated') {
|
||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
||||
}
|
||||
tx.updatedAt = Date.now()
|
||||
await this.saveTransaction(tx)
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const result: TransactionLog[] = []
|
||||
|
||||
if (serverId) {
|
||||
const serverKey = `${this._prefix}server:${serverId}:txs`
|
||||
const txIds = await this._client.hgetall(serverKey)
|
||||
|
||||
for (const id of Object.keys(txIds)) {
|
||||
const tx = await this.getTransaction(id)
|
||||
if (tx && (tx.state === 'pending' || tx.state === 'executing')) {
|
||||
result.push(tx)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pattern = `${this._prefix}tx:*`
|
||||
const keys = await this._client.keys(pattern)
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this._client.get(key)
|
||||
if (data) {
|
||||
const tx: TransactionLog = JSON.parse(data)
|
||||
if (tx.state === 'pending' || tx.state === 'executing') {
|
||||
result.push(tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
const key = `${this._prefix}tx:${id}`
|
||||
const tx = await this.getTransaction(id)
|
||||
|
||||
await this._client.del(key)
|
||||
|
||||
if (tx?.metadata?.serverId) {
|
||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`
|
||||
await this._client.hdel(serverKey, id)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 数据操作 | Data Operations
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
const data = await this._client.get(fullKey)
|
||||
|
||||
return data ? JSON.parse(data) : null
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
|
||||
if (ttl) {
|
||||
const ttlSeconds = Math.ceil(ttl / 1000)
|
||||
await this._client.set(fullKey, JSON.stringify(value), 'EX', String(ttlSeconds))
|
||||
} else {
|
||||
await this._client.set(fullKey, JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
const result = await this._client.del(fullKey)
|
||||
return result > 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Redis 存储
|
||||
* @en Create Redis storage
|
||||
*/
|
||||
export function createRedisStorage(config: RedisStorageConfig): RedisStorage {
|
||||
return new RedisStorage(config)
|
||||
}
|
||||
8
packages/framework/transaction/src/storage/index.ts
Normal file
8
packages/framework/transaction/src/storage/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @zh 存储模块导出
|
||||
* @en Storage module exports
|
||||
*/
|
||||
|
||||
export { MemoryStorage, createMemoryStorage, type MemoryStorageConfig } from './MemoryStorage.js'
|
||||
export { RedisStorage, createRedisStorage, type RedisStorageConfig, type RedisClient } from './RedisStorage.js'
|
||||
export { MongoStorage, createMongoStorage, type MongoStorageConfig, type MongoDb, type MongoCollection } from './MongoStorage.js'
|
||||
20
packages/framework/transaction/src/tokens.ts
Normal file
20
packages/framework/transaction/src/tokens.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @zh Transaction 模块服务令牌
|
||||
* @en Transaction module service tokens
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework'
|
||||
import type { TransactionManager } from './core/TransactionManager.js'
|
||||
import type { ITransactionStorage } from './core/types.js'
|
||||
|
||||
/**
|
||||
* @zh 事务管理器令牌
|
||||
* @en Transaction manager token
|
||||
*/
|
||||
export const TransactionManagerToken = createServiceToken<TransactionManager>('transactionManager')
|
||||
|
||||
/**
|
||||
* @zh 事务存储令牌
|
||||
* @en Transaction storage token
|
||||
*/
|
||||
export const TransactionStorageToken = createServiceToken<ITransactionStorage>('transactionStorage')
|
||||
12
packages/framework/transaction/tsconfig.build.json
Normal file
12
packages/framework/transaction/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
16
packages/framework/transaction/tsconfig.json
Normal file
16
packages/framework/transaction/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../../core" },
|
||||
{ "path": "../server" }
|
||||
]
|
||||
}
|
||||
12
packages/framework/transaction/tsup.config.ts
Normal file
12
packages/framework/transaction/tsup.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup'
|
||||
|
||||
export default defineConfig({
|
||||
...runtimeOnlyPreset({
|
||||
external: ['ioredis', 'mongodb'],
|
||||
}),
|
||||
tsconfig: 'tsconfig.build.json',
|
||||
// tsup 的 DTS bundler 无法正确解析 workspace 包的类型继承链
|
||||
// tsup's DTS bundler cannot correctly resolve workspace package type inheritance
|
||||
dts: false,
|
||||
})
|
||||
223
pnpm-lock.yaml
generated
223
pnpm-lock.yaml
generated
@@ -158,16 +158,16 @@ importers:
|
||||
dependencies:
|
||||
'@astrojs/starlight':
|
||||
specifier: ^0.37.1
|
||||
version: 0.37.1(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
version: 0.37.1(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
'@astrojs/vue':
|
||||
specifier: ^5.1.3
|
||||
version: 5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)
|
||||
version: 5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
astro:
|
||||
specifier: ^5.6.1
|
||||
version: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
version: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
sharp:
|
||||
specifier: ^0.34.2
|
||||
version: 0.34.5
|
||||
@@ -1763,6 +1763,34 @@ importers:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/framework/transaction:
|
||||
dependencies:
|
||||
'@esengine/server':
|
||||
specifier: workspace:*
|
||||
version: link:../server
|
||||
ioredis:
|
||||
specifier: ^5.3.0
|
||||
version: 5.8.2
|
||||
mongodb:
|
||||
specifier: ^6.0.0
|
||||
version: 6.21.0(socks@2.8.7)
|
||||
devDependencies:
|
||||
'@esengine/build-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../tools/build-config
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../core/dist
|
||||
rimraf:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/framework/world-streaming:
|
||||
dependencies:
|
||||
'@esengine/ecs-framework':
|
||||
@@ -3824,6 +3852,9 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.4.0':
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -3984,6 +4015,9 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@mongodb-js/saslprep@1.4.4':
|
||||
resolution: {integrity: sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==}
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||
cpu: [arm64]
|
||||
@@ -5238,6 +5272,12 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webidl-conversions@7.0.3':
|
||||
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
|
||||
|
||||
'@types/whatwg-url@11.0.5':
|
||||
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -5825,6 +5865,10 @@ packages:
|
||||
bser@2.1.1:
|
||||
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
|
||||
|
||||
bson@6.10.4:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -6011,6 +6055,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
cmd-shim@6.0.3:
|
||||
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -6378,6 +6426,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
deprecation@2.3.1:
|
||||
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
|
||||
|
||||
@@ -7400,6 +7452,10 @@ packages:
|
||||
resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ioredis@5.8.2:
|
||||
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -8052,6 +8108,9 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
@@ -8059,6 +8118,9 @@ packages:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
@@ -8286,6 +8348,9 @@ packages:
|
||||
resolution: {integrity: sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
meow@12.1.1:
|
||||
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
|
||||
engines: {node: '>=16.10'}
|
||||
@@ -8543,6 +8608,36 @@ packages:
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
mongodb-connection-string-url@3.0.2:
|
||||
resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==}
|
||||
|
||||
mongodb@6.21.0:
|
||||
resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/credential-providers': ^3.188.0
|
||||
'@mongodb-js/zstd': ^1.1.0 || ^2.0.0
|
||||
gcp-metadata: ^5.2.0
|
||||
kerberos: ^2.0.1
|
||||
mongodb-client-encryption: '>=6.0.0 <7'
|
||||
snappy: ^7.3.2
|
||||
socks: ^2.7.1
|
||||
peerDependenciesMeta:
|
||||
'@aws-sdk/credential-providers':
|
||||
optional: true
|
||||
'@mongodb-js/zstd':
|
||||
optional: true
|
||||
gcp-metadata:
|
||||
optional: true
|
||||
kerberos:
|
||||
optional: true
|
||||
mongodb-client-encryption:
|
||||
optional: true
|
||||
snappy:
|
||||
optional: true
|
||||
socks:
|
||||
optional: true
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -9457,6 +9552,14 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
@@ -9843,6 +9946,9 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
sparse-bitfield@3.0.3:
|
||||
resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==}
|
||||
|
||||
spawn-error-forwarder@1.0.0:
|
||||
resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==}
|
||||
|
||||
@@ -9889,6 +9995,9 @@ packages:
|
||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
@@ -10141,6 +10250,10 @@ packages:
|
||||
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tr46@5.1.1:
|
||||
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
traverse@0.6.8:
|
||||
resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -10904,6 +11017,10 @@ packages:
|
||||
resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
@@ -11262,12 +11379,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/mdx@4.3.13(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
'@astrojs/mdx@4.3.13(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@astrojs/markdown-remark': 6.3.10
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
acorn: 8.15.0
|
||||
astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
es-module-lexer: 1.7.0
|
||||
estree-util-visit: 2.0.0
|
||||
hast-util-to-html: 9.0.5
|
||||
@@ -11291,17 +11408,17 @@ snapshots:
|
||||
stream-replace-string: 2.0.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
'@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@astrojs/markdown-remark': 6.3.10
|
||||
'@astrojs/mdx': 4.3.13(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
'@astrojs/mdx': 4.3.13(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
'@astrojs/sitemap': 3.6.0
|
||||
'@pagefind/default-ui': 1.4.0
|
||||
'@types/hast': 3.0.4
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/mdast': 4.0.4
|
||||
astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
astro-expressive-code: 0.41.5(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
astro-expressive-code: 0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
|
||||
bcp-47: 2.1.0
|
||||
hast-util-from-html: 2.0.3
|
||||
hast-util-select: 6.0.4
|
||||
@@ -11337,12 +11454,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/vue@5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)':
|
||||
'@astrojs/vue@5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)':
|
||||
dependencies:
|
||||
'@vitejs/plugin-vue': 5.2.1(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
'@vue/compiler-sfc': 3.5.26
|
||||
astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-vue-devtools: 7.7.9(rollup@4.54.0)(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
@@ -12902,6 +13019,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@ioredis/commands@1.4.0': {}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
@@ -13359,6 +13478,10 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@mongodb-js/saslprep@1.4.4':
|
||||
dependencies:
|
||||
sparse-bitfield: 3.0.3
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
optional: true
|
||||
|
||||
@@ -14732,6 +14855,12 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.3': {}
|
||||
|
||||
'@types/whatwg-url@11.0.5':
|
||||
dependencies:
|
||||
'@types/webidl-conversions': 7.0.3
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 20.19.27
|
||||
@@ -15280,12 +15409,12 @@ snapshots:
|
||||
|
||||
astring@1.9.0: {}
|
||||
|
||||
astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)):
|
||||
astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
rehype-expressive-code: 0.41.5
|
||||
|
||||
astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
|
||||
astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
'@astrojs/internal-helpers': 0.7.5
|
||||
@@ -15340,7 +15469,7 @@ snapshots:
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.6.0
|
||||
unist-util-visit: 5.0.0
|
||||
unstorage: 1.17.3
|
||||
unstorage: 1.17.3(ioredis@5.8.2)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
@@ -15571,6 +15700,8 @@ snapshots:
|
||||
dependencies:
|
||||
node-int64: 0.4.0
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -15750,6 +15881,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
cmd-shim@6.0.3: {}
|
||||
|
||||
co@4.6.0: {}
|
||||
@@ -16094,6 +16227,8 @@ snapshots:
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
deprecation@2.3.1: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
@@ -17421,6 +17556,20 @@ snapshots:
|
||||
|
||||
invert-kv@3.0.1: {}
|
||||
|
||||
ioredis@5.8.2:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.4.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
@@ -18307,10 +18456,14 @@ snapshots:
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
@@ -18662,6 +18815,8 @@ snapshots:
|
||||
mimic-fn: 2.1.0
|
||||
p-is-promise: 2.1.0
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
meow@12.1.1: {}
|
||||
|
||||
meow@8.1.2:
|
||||
@@ -19078,6 +19233,19 @@ snapshots:
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
mongodb-connection-string-url@3.0.2:
|
||||
dependencies:
|
||||
'@types/whatwg-url': 11.0.5
|
||||
whatwg-url: 14.2.0
|
||||
|
||||
mongodb@6.21.0(socks@2.8.7):
|
||||
dependencies:
|
||||
'@mongodb-js/saslprep': 1.4.4
|
||||
bson: 6.10.4
|
||||
mongodb-connection-string-url: 3.0.2
|
||||
optionalDependencies:
|
||||
socks: 2.8.7
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
@@ -20008,6 +20176,12 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
regenerate-unicode-properties@10.2.2:
|
||||
@@ -20545,6 +20719,10 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
sparse-bitfield@3.0.3:
|
||||
dependencies:
|
||||
memory-pager: 1.5.0
|
||||
|
||||
spawn-error-forwarder@1.0.0: {}
|
||||
|
||||
spawndamnit@3.0.1:
|
||||
@@ -20592,6 +20770,8 @@ snapshots:
|
||||
dependencies:
|
||||
escape-string-regexp: 2.0.0
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
stream-combiner2@1.1.1:
|
||||
@@ -20853,6 +21033,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tr46@5.1.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
traverse@0.6.8: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
@@ -21321,7 +21505,7 @@ snapshots:
|
||||
picomatch: 4.0.3
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
unstorage@1.17.3:
|
||||
unstorage@1.17.3(ioredis@5.8.2):
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
chokidar: 4.0.3
|
||||
@@ -21331,6 +21515,8 @@ snapshots:
|
||||
node-fetch-native: 1.6.7
|
||||
ofetch: 1.5.1
|
||||
ufo: 1.6.1
|
||||
optionalDependencies:
|
||||
ioredis: 5.8.2
|
||||
|
||||
upath@2.0.1: {}
|
||||
|
||||
@@ -21661,6 +21847,11 @@ snapshots:
|
||||
tr46: 3.0.0
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
whatwg-url@14.2.0:
|
||||
dependencies:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
|
||||
Reference in New Issue
Block a user