feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)

## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

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

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

## Docs
- Update storage.md for new factory pattern API
This commit is contained in:
YHH
2025-12-29 15:02:13 +08:00
committed by GitHub
parent 10c3891abd
commit 3b978384c7
50 changed files with 7591 additions and 660 deletions

View File

@@ -6,8 +6,8 @@
import type {
ITransactionOperation,
ITransactionContext,
OperationResult,
} from '../core/types.js'
OperationResult
} from '../core/types.js';
/**
* @zh 操作基类
@@ -17,13 +17,13 @@ import type {
* @en Provides common operation implementation template
*/
export abstract class BaseOperation<TData = unknown, TResult = unknown>
implements ITransactionOperation<TData, TResult>
implements ITransactionOperation<TData, TResult>
{
abstract readonly name: string
readonly data: TData
readonly data: TData;
constructor(data: TData) {
this.data = data
this.data = data;
}
/**
@@ -31,7 +31,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
* @en Validate preconditions (passes by default)
*/
async validate(_ctx: ITransactionContext): Promise<boolean> {
return true
return true;
}
/**
@@ -51,7 +51,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
* @en Create success result
*/
protected success(data?: TResult): OperationResult<TResult> {
return { success: true, data }
return { success: true, data };
}
/**
@@ -59,6 +59,6 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
* @en Create failure result
*/
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
return { success: false, error, errorCode }
return { success: false, error, errorCode };
}
}

View File

@@ -3,8 +3,8 @@
* @en Currency operation
*/
import type { ITransactionContext, OperationResult } from '../core/types.js'
import { BaseOperation } from './BaseOperation.js'
import type { ITransactionContext, OperationResult } from '../core/types.js';
import { BaseOperation } from './BaseOperation.js';
/**
* @zh 货币操作类型
@@ -112,89 +112,89 @@ export interface ICurrencyProvider {
* ```
*/
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
readonly name = 'currency'
readonly name = 'currency';
private _provider: ICurrencyProvider | null = null
private _beforeBalance: number = 0
private _provider: ICurrencyProvider | null = null;
private _beforeBalance: number = 0;
/**
* @zh 设置货币数据提供者
* @en Set currency data provider
*/
setProvider(provider: ICurrencyProvider): this {
this._provider = provider
return this
this._provider = provider;
return this;
}
async validate(ctx: ITransactionContext): Promise<boolean> {
if (this.data.amount <= 0) {
return false
return false;
}
if (this.data.type === 'deduct') {
const balance = await this._getBalance(ctx)
return balance >= this.data.amount
const balance = await this._getBalance(ctx);
return balance >= this.data.amount;
}
return true
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
const { type, playerId, currency, amount } = this.data
const { type, playerId, currency, amount } = this.data;
this._beforeBalance = await this._getBalance(ctx)
this._beforeBalance = await this._getBalance(ctx);
let afterBalance: number
let afterBalance: number;
if (type === 'add') {
afterBalance = this._beforeBalance + amount
afterBalance = this._beforeBalance + amount;
} else {
if (this._beforeBalance < amount) {
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE')
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE');
}
afterBalance = this._beforeBalance - amount
afterBalance = this._beforeBalance - amount;
}
await this._setBalance(ctx, afterBalance)
await this._setBalance(ctx, afterBalance);
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance)
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance)
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance);
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance);
return this.success({
beforeBalance: this._beforeBalance,
afterBalance,
})
afterBalance
});
}
async compensate(ctx: ITransactionContext): Promise<void> {
await this._setBalance(ctx, this._beforeBalance)
await this._setBalance(ctx, this._beforeBalance);
}
private async _getBalance(ctx: ITransactionContext): Promise<number> {
const { playerId, currency } = this.data
const { playerId, currency } = this.data;
if (this._provider) {
return this._provider.getBalance(playerId, currency)
return this._provider.getBalance(playerId, currency);
}
if (ctx.storage) {
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`)
return balance ?? 0
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`);
return balance ?? 0;
}
return 0
return 0;
}
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
const { playerId, currency } = this.data
const { playerId, currency } = this.data;
if (this._provider) {
await this._provider.setBalance(playerId, currency, amount)
return
await this._provider.setBalance(playerId, currency, amount);
return;
}
if (ctx.storage) {
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount)
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount);
}
}
}
@@ -204,5 +204,5 @@ export class CurrencyOperation extends BaseOperation<CurrencyOperationData, Curr
* @en Create currency operation
*/
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
return new CurrencyOperation(data)
return new CurrencyOperation(data);
}

View File

@@ -3,8 +3,8 @@
* @en Inventory operation
*/
import type { ITransactionContext, OperationResult } from '../core/types.js'
import { BaseOperation } from './BaseOperation.js'
import type { ITransactionContext, OperationResult } from '../core/types.js';
import { BaseOperation } from './BaseOperation.js';
/**
* @zh 背包操作类型
@@ -147,136 +147,136 @@ export interface IInventoryProvider {
* ```
*/
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
readonly name = 'inventory'
readonly name = 'inventory';
private _provider: IInventoryProvider | null = null
private _beforeItem: ItemData | null = null
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
this._provider = provider;
return this;
}
async validate(ctx: ITransactionContext): Promise<boolean> {
const { type, quantity } = this.data
const { type, quantity } = this.data;
if (quantity <= 0) {
return false
return false;
}
if (type === 'remove') {
const item = await this._getItem(ctx)
return item !== null && item.quantity >= quantity
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 this._provider.hasCapacity(this.data.playerId, 1);
}
return true
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
const { type, playerId, itemId, quantity, properties } = this.data
const { type, playerId, itemId, quantity, properties } = this.data;
this._beforeItem = await this._getItem(ctx)
this._beforeItem = await this._getItem(ctx);
let afterItem: ItemData | null = null
let afterItem: ItemData | null = null;
switch (type) {
case 'add': {
if (this._beforeItem) {
afterItem = {
...this._beforeItem,
quantity: this._beforeItem.quantity + quantity,
}
quantity: this._beforeItem.quantity + quantity
};
} else {
afterItem = {
itemId,
quantity,
properties,
}
properties
};
}
break
break;
}
case 'remove': {
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM')
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM');
}
const newQuantity = this._beforeItem.quantity - quantity
const newQuantity = this._beforeItem.quantity - quantity;
if (newQuantity > 0) {
afterItem = {
...this._beforeItem,
quantity: newQuantity,
}
quantity: newQuantity
};
} else {
afterItem = null
afterItem = null;
}
break
break;
}
case 'update': {
if (!this._beforeItem) {
return this.failure('Item not found', 'ITEM_NOT_FOUND')
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
properties: properties ?? this._beforeItem.properties
};
break;
}
}
await this._setItem(ctx, afterItem)
await this._setItem(ctx, afterItem);
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem)
ctx.set(`inventory:${playerId}:${itemId}:after`, 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,
})
afterItem: afterItem ?? undefined
});
}
async compensate(ctx: ITransactionContext): Promise<void> {
await this._setItem(ctx, this._beforeItem)
await this._setItem(ctx, this._beforeItem);
}
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
const { playerId, itemId } = this.data
const { playerId, itemId } = this.data;
if (this._provider) {
return this._provider.getItem(playerId, itemId)
return this._provider.getItem(playerId, itemId);
}
if (ctx.storage) {
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`)
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`);
}
return null
return null;
}
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
const { playerId, itemId } = this.data
const { playerId, itemId } = this.data;
if (this._provider) {
await this._provider.setItem(playerId, itemId, item)
return
await this._provider.setItem(playerId, itemId, item);
return;
}
if (ctx.storage) {
if (item) {
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item)
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item);
} else {
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`)
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`);
}
}
}
@@ -287,5 +287,5 @@ export class InventoryOperation extends BaseOperation<InventoryOperationData, In
* @en Create inventory operation
*/
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
return new InventoryOperation(data)
return new InventoryOperation(data);
}

View File

@@ -3,10 +3,10 @@
* @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'
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 交易物品
@@ -148,67 +148,67 @@ export interface ITradeProvider {
* ```
*/
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
readonly name = 'trade'
readonly name = 'trade';
private _provider: ITradeProvider | null = null
private _subOperations: (CurrencyOperation | InventoryOperation)[] = []
private _executedCount = 0
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
this._provider = provider;
return this;
}
async validate(ctx: ITransactionContext): Promise<boolean> {
this._buildSubOperations()
this._buildSubOperations();
for (const op of this._subOperations) {
const isValid = await op.validate(ctx)
const isValid = await op.validate(ctx);
if (!isValid) {
return false
return false;
}
}
return true
return true;
}
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
this._buildSubOperations()
this._executedCount = 0
this._buildSubOperations();
this._executedCount = 0;
try {
for (const op of this._subOperations) {
const result = await op.execute(ctx)
const result = await op.execute(ctx);
if (!result.success) {
await this._compensateExecuted(ctx)
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED')
await this._compensateExecuted(ctx);
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED');
}
this._executedCount++
this._executedCount++;
}
return this.success({
tradeId: this.data.tradeId,
completed: true,
})
completed: true
});
} catch (error) {
await this._compensateExecuted(ctx)
const errorMessage = error instanceof Error ? error.message : String(error)
return this.failure(errorMessage, 'TRADE_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)
await this._compensateExecuted(ctx);
}
private _buildSubOperations(): void {
if (this._subOperations.length > 0) return
if (this._subOperations.length > 0) return;
const { partyA, partyB } = this.data
const { partyA, partyB } = this.data;
if (partyA.items) {
for (const item of partyA.items) {
@@ -217,22 +217,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
playerId: partyA.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:give`,
})
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`,
})
reason: `trade:${this.data.tradeId}:receive`
});
if (this._provider?.inventoryProvider) {
removeOp.setProvider(this._provider.inventoryProvider)
addOp.setProvider(this._provider.inventoryProvider)
removeOp.setProvider(this._provider.inventoryProvider);
addOp.setProvider(this._provider.inventoryProvider);
}
this._subOperations.push(removeOp, addOp)
this._subOperations.push(removeOp, addOp);
}
}
@@ -243,22 +243,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
playerId: partyA.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:give`,
})
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`,
})
reason: `trade:${this.data.tradeId}:receive`
});
if (this._provider?.currencyProvider) {
deductOp.setProvider(this._provider.currencyProvider)
addOp.setProvider(this._provider.currencyProvider)
deductOp.setProvider(this._provider.currencyProvider);
addOp.setProvider(this._provider.currencyProvider);
}
this._subOperations.push(deductOp, addOp)
this._subOperations.push(deductOp, addOp);
}
}
@@ -269,22 +269,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
playerId: partyB.playerId,
itemId: item.itemId,
quantity: item.quantity,
reason: `trade:${this.data.tradeId}:give`,
})
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`,
})
reason: `trade:${this.data.tradeId}:receive`
});
if (this._provider?.inventoryProvider) {
removeOp.setProvider(this._provider.inventoryProvider)
addOp.setProvider(this._provider.inventoryProvider)
removeOp.setProvider(this._provider.inventoryProvider);
addOp.setProvider(this._provider.inventoryProvider);
}
this._subOperations.push(removeOp, addOp)
this._subOperations.push(removeOp, addOp);
}
}
@@ -295,29 +295,29 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
playerId: partyB.playerId,
currency: curr.currency,
amount: curr.amount,
reason: `trade:${this.data.tradeId}:give`,
})
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`,
})
reason: `trade:${this.data.tradeId}:receive`
});
if (this._provider?.currencyProvider) {
deductOp.setProvider(this._provider.currencyProvider)
addOp.setProvider(this._provider.currencyProvider)
deductOp.setProvider(this._provider.currencyProvider);
addOp.setProvider(this._provider.currencyProvider);
}
this._subOperations.push(deductOp, addOp)
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)
await this._subOperations[i].compensate(ctx);
}
}
}
@@ -327,5 +327,5 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
* @en Create trade operation
*/
export function createTradeOperation(data: TradeOperationData): TradeOperation {
return new TradeOperation(data)
return new TradeOperation(data);
}

View File

@@ -3,7 +3,7 @@
* @en Operations module exports
*/
export { BaseOperation } from './BaseOperation.js'
export { BaseOperation } from './BaseOperation.js';
export {
CurrencyOperation,
@@ -11,8 +11,8 @@ export {
type CurrencyOperationType,
type CurrencyOperationData,
type CurrencyOperationResult,
type ICurrencyProvider,
} from './CurrencyOperation.js'
type ICurrencyProvider
} from './CurrencyOperation.js';
export {
InventoryOperation,
@@ -21,8 +21,8 @@ export {
type InventoryOperationData,
type InventoryOperationResult,
type IInventoryProvider,
type ItemData,
} from './InventoryOperation.js'
type ItemData
} from './InventoryOperation.js';
export {
TradeOperation,
@@ -32,5 +32,5 @@ export {
type TradeItem,
type TradeCurrency,
type TradeParty,
type ITradeProvider,
} from './TradeOperation.js'
type ITradeProvider
} from './TradeOperation.js';