From 6b8b65ae16fd0e6092e778bb7ee241d7c5627bac Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Thu, 25 Dec 2025 11:00:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(script-runtime):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=AB=AF=E8=93=9D=E5=9B=BE=E6=89=A7=E8=A1=8C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(script-runtime): 添加服务器端蓝图执行模块 - ServerBlueprintVM: 服务器端蓝图虚拟机 - CPULimiter: CPU 时间和步数限制 - IntentCollector: 意图收集系统 - FileMemoryStore: 文件系统持久化 - ServerExecutionContext: 服务器执行上下文 * refactor(script-runtime): 分离引擎接口与游戏逻辑 - 重构 IntentTypes.ts 只保留基础 IIntent 接口和通用常量 - IntentCollector 改为泛型类,支持任意意图类型 - ServerExecutionContext 改为泛型类,支持任意游戏状态类型 - ServerBlueprintVM 改为泛型类,使用 TGameState 和 TIntent 类型参数 - 移除游戏特定类型(IUnitState, ISpawnerState 等),由游戏项目定义 - 添加 IntentKeyExtractor 机制用于防止重复意图 * feat(script-runtime): 添加服务器端游戏循环框架 - PlayerSession: 封装单个玩家的 VM、蓝图和 Memory 状态 - TickScheduler: 管理所有玩家会话,调度每 tick 的蓝图执行 - IIntentProcessor: 意图处理器接口,由游戏项目实现 - IntentProcessorBase: 意图处理器基类,提供常用处理模式 - IntentProcessorRegistry: 按类型注册意图处理器 - GameLoop: 完整的游戏主循环,协调各组件工作 * feat(script-runtime): 添加通用蓝图节点 Memory 节点: - GetMemory: 读取玩家 Memory - SetMemory: 写入玩家 Memory - HasMemoryKey: 检查键是否存在 - DeleteMemory: 删除 Memory 键 Log 节点: - Log: 记录日志 - Warn: 记录警告 - Error: 记录错误 Game 信息节点: - GetTick: 获取当前 tick - GetPlayerId: 获取玩家 ID - GetDeltaTime: 获取增量时间 - GetGameState: 获取游戏状态 提供 registerScriptRuntimeNodes() 用于批量注册节点 * fix(script-runtime): 修复 CI 构建错误 - 更新 tsconfig.json 继承 tsconfig.base.json - 添加 references 到 core 和 blueprint 包 - 更新 pnpm-lock.yaml * fix(script-runtime): 修复 DTS 构建错误 - 添加 tsconfig.build.json 用于 tsup 构建 - 更新 tsup.config.ts 使用 tsconfig.build.json - 分离构建配置和类型检查配置 --- packages/script-runtime/module.json | 39 ++ packages/script-runtime/package.json | 56 ++ packages/script-runtime/src/index.ts | 143 ++++ .../src/intent/IntentCollector.ts | 214 ++++++ .../script-runtime/src/intent/IntentTypes.ts | 138 ++++ .../script-runtime/src/nodes/GameInfoNodes.ts | 201 ++++++ packages/script-runtime/src/nodes/LogNodes.ts | 200 ++++++ .../script-runtime/src/nodes/MemoryNodes.ts | 269 ++++++++ packages/script-runtime/src/nodes/index.ts | 84 +++ .../src/persistence/FileMemoryStore.ts | 339 ++++++++++ .../src/persistence/IMemoryStore.ts | 131 ++++ .../script-runtime/src/server/GameLoop.ts | 368 +++++++++++ .../src/server/IIntentProcessor.ts | 248 +++++++ .../src/server/PlayerSession.ts | 294 +++++++++ .../src/server/TickScheduler.ts | 419 ++++++++++++ packages/script-runtime/src/server/index.ts | 30 + packages/script-runtime/src/server/types.ts | 233 +++++++ packages/script-runtime/src/tokens.ts | 78 +++ packages/script-runtime/src/vm/CPULimiter.ts | 318 +++++++++ .../src/vm/ServerBlueprintVM.ts | 618 ++++++++++++++++++ .../src/vm/ServerExecutionContext.ts | 459 +++++++++++++ packages/script-runtime/tsconfig.build.json | 22 + packages/script-runtime/tsconfig.json | 14 + packages/script-runtime/tsup.config.ts | 15 + pnpm-lock.yaml | 31 + 25 files changed, 4961 insertions(+) create mode 100644 packages/script-runtime/module.json create mode 100644 packages/script-runtime/package.json create mode 100644 packages/script-runtime/src/index.ts create mode 100644 packages/script-runtime/src/intent/IntentCollector.ts create mode 100644 packages/script-runtime/src/intent/IntentTypes.ts create mode 100644 packages/script-runtime/src/nodes/GameInfoNodes.ts create mode 100644 packages/script-runtime/src/nodes/LogNodes.ts create mode 100644 packages/script-runtime/src/nodes/MemoryNodes.ts create mode 100644 packages/script-runtime/src/nodes/index.ts create mode 100644 packages/script-runtime/src/persistence/FileMemoryStore.ts create mode 100644 packages/script-runtime/src/persistence/IMemoryStore.ts create mode 100644 packages/script-runtime/src/server/GameLoop.ts create mode 100644 packages/script-runtime/src/server/IIntentProcessor.ts create mode 100644 packages/script-runtime/src/server/PlayerSession.ts create mode 100644 packages/script-runtime/src/server/TickScheduler.ts create mode 100644 packages/script-runtime/src/server/index.ts create mode 100644 packages/script-runtime/src/server/types.ts create mode 100644 packages/script-runtime/src/tokens.ts create mode 100644 packages/script-runtime/src/vm/CPULimiter.ts create mode 100644 packages/script-runtime/src/vm/ServerBlueprintVM.ts create mode 100644 packages/script-runtime/src/vm/ServerExecutionContext.ts create mode 100644 packages/script-runtime/tsconfig.build.json create mode 100644 packages/script-runtime/tsconfig.json create mode 100644 packages/script-runtime/tsup.config.ts diff --git a/packages/script-runtime/module.json b/packages/script-runtime/module.json new file mode 100644 index 00000000..680812b9 --- /dev/null +++ b/packages/script-runtime/module.json @@ -0,0 +1,39 @@ +{ + "id": "script-runtime", + "name": "@esengine/script-runtime", + "globalKey": "scriptRuntime", + "displayName": "Script Runtime", + "description": "Server-side blueprint execution for programmable strategy games | 服务器端蓝图执行,用于可编程策略游戏", + "version": "1.0.0", + "category": "Other", + "icon": "Code", + "tags": [ + "scripting", + "server", + "blueprint", + "sandbox", + "programmable" + ], + "isCore": false, + "defaultEnabled": false, + "isEngineModule": true, + "canContainContent": false, + "platforms": [ + "desktop" + ], + "dependencies": [ + "core", + "blueprint" + ], + "exports": { + "other": [ + "ServerBlueprintVM", + "IntentCollector", + "CPULimiter", + "FileMemoryStore" + ] + }, + "requiresWasm": false, + "outputPath": "dist/index.js", + "pluginExport": "ScriptRuntimePlugin" +} diff --git a/packages/script-runtime/package.json b/packages/script-runtime/package.json new file mode 100644 index 00000000..34b68969 --- /dev/null +++ b/packages/script-runtime/package.json @@ -0,0 +1,56 @@ +{ + "name": "@esengine/script-runtime", + "version": "1.0.0", + "description": "Server-side blueprint execution for programmable strategy games | 服务器端蓝图执行,用于可编程策略游戏", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "module.json" + ], + "scripts": { + "clean": "rimraf dist tsconfig.tsbuildinfo", + "build": "tsup", + "build:watch": "tsup --watch", + "type-check": "tsc --noEmit" + }, + "keywords": [ + "ecs", + "script-runtime", + "screeps", + "server-side", + "game-engine" + ], + "author": "yhh", + "license": "MIT", + "devDependencies": { + "@esengine/ecs-framework": "workspace:*", + "@esengine/engine-core": "workspace:*", + "@esengine/blueprint": "workspace:*", + "@esengine/build-config": "workspace:*", + "@types/node": "^20.19.17", + "rimraf": "^5.0.0", + "tsup": "^8.0.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/esengine.git", + "directory": "packages/script-runtime" + } +} diff --git a/packages/script-runtime/src/index.ts b/packages/script-runtime/src/index.ts new file mode 100644 index 00000000..048243dc --- /dev/null +++ b/packages/script-runtime/src/index.ts @@ -0,0 +1,143 @@ +/** + * @esengine/script-runtime + * + * Server-side blueprint execution for programmable strategy games + * 服务器端蓝图执行,用于可编程策略游戏 + * + * @packageDocumentation + */ + +// ============================================================================= +// VM | Virtual Machine +// ============================================================================= + +export { ServerBlueprintVM } from './vm/ServerBlueprintVM'; +export type { ServerVMConfig, TickResult } from './vm/ServerBlueprintVM'; + +export { ServerExecutionContext } from './vm/ServerExecutionContext'; +export type { IGameState, LogEntry } from './vm/ServerExecutionContext'; + +export { CPULimiter, DEFAULT_CPU_CONFIG } from './vm/CPULimiter'; +export type { CPULimiterConfig, CPUStats } from './vm/CPULimiter'; + +// ============================================================================= +// Intent System | 意图系统 +// ============================================================================= + +export { IntentCollector } from './intent/IntentCollector'; +export type { IIntentCollector } from './intent/IntentCollector'; + +export type { IIntent, IntentKeyExtractor, Direction } from './intent/IntentTypes'; + +export { defaultIntentKeyExtractor } from './intent/IntentTypes'; + +// Result constants +export { + OK, + ERR_GENERIC, + ERR_NOT_OWNER, + ERR_INVALID_TARGET, + ERR_NOT_IN_RANGE, + ERR_NOT_ENOUGH_RESOURCES, + ERR_BUSY, + ERR_INVALID_ARGS, + // Direction constants + TOP, + TOP_RIGHT, + RIGHT, + BOTTOM_RIGHT, + BOTTOM, + BOTTOM_LEFT, + LEFT, + TOP_LEFT +} from './intent/IntentTypes'; + +// ============================================================================= +// Persistence | 持久化 +// ============================================================================= + +export { FileMemoryStore } from './persistence/FileMemoryStore'; +export type { FileMemoryStoreConfig } from './persistence/FileMemoryStore'; + +export type { + IMemoryStore, + PlayerMemory, + WorldState, + MemoryStoreStats +} from './persistence/IMemoryStore'; + +// ============================================================================= +// Service Tokens | 服务令牌 +// ============================================================================= + +export { + ScriptRuntimeServiceToken, + MemoryStoreToken +} from './tokens'; + +export type { IScriptRuntimeService } from './tokens'; + +// ============================================================================= +// Server | 服务器端 +// ============================================================================= + +export { + // PlayerSession + PlayerSession, + // TickScheduler + TickScheduler, + // IntentProcessor + IntentProcessorBase, + IntentProcessorRegistry, + // GameLoop + GameLoop, + DEFAULT_GAME_LOOP_CONFIG +} from './server'; + +export type { + // Types + PlayerTickResult, + TickExecutionResult, + IntentProcessingResult, + GameLoopConfig, + GameLoopState, + GameLoopEvents, + // PlayerSession + PlayerSessionConfig, + PlayerSessionState, + // TickScheduler + TickSchedulerConfig, + SchedulerStats, + // IntentProcessor + IIntentProcessor, + SingleIntentResult, + // GameLoop + GameLoopStats +} from './server'; + +// ============================================================================= +// Nodes | 蓝图节点 +// ============================================================================= + +export { + // Registration + registerScriptRuntimeNodes, + AllNodeDefinitions, + // Memory Nodes + GetMemoryTemplate, + SetMemoryTemplate, + HasMemoryKeyTemplate, + DeleteMemoryTemplate, + MemoryNodeDefinitions, + // Log Nodes + LogTemplate, + WarnTemplate, + ErrorTemplate, + LogNodeDefinitions, + // Game Info Nodes + GetTickTemplate, + GetPlayerIdTemplate, + GetDeltaTimeTemplate, + GetGameStateTemplate, + GameInfoNodeDefinitions +} from './nodes'; diff --git a/packages/script-runtime/src/intent/IntentCollector.ts b/packages/script-runtime/src/intent/IntentCollector.ts new file mode 100644 index 00000000..621a747a --- /dev/null +++ b/packages/script-runtime/src/intent/IntentCollector.ts @@ -0,0 +1,214 @@ +/** + * @zh 意图收集器 + * @en Intent Collector + * + * @zh 收集玩家蓝图执行过程中产生的所有意图 + * @en Collects all intents generated during player blueprint execution + */ + +import type { IIntent, IntentKeyExtractor } from './IntentTypes'; +import { defaultIntentKeyExtractor } from './IntentTypes'; + +/** + * @zh 意图收集器接口 + * @en Intent collector interface + */ +export interface IIntentCollector { + /** + * @zh 添加一个意图 + * @en Add an intent + */ + addIntent(intent: T): boolean; + + /** + * @zh 获取所有收集的意图 + * @en Get all collected intents + */ + getIntents(): T[]; + + /** + * @zh 按类型获取意图 + * @en Get intents by type + */ + getIntentsByType(type: string): T[]; + + /** + * @zh 清除所有意图 + * @en Clear all intents + */ + clear(): void; + + /** + * @zh 获取意图数量 + * @en Get intent count + */ + readonly count: number; +} + +/** + * @zh 意图收集器 + * @en Intent Collector + * + * @zh 在蓝图执行过程中收集玩家的操作意图,执行完成后由服务器统一处理 + * @en Collects player operation intents during blueprint execution, processed by server after execution + * + * @typeParam T - @zh 意图类型,必须继承 IIntent @en Intent type, must extend IIntent + * + * @example + * ```typescript + * // 游戏项目中定义意图类型 | Define intent types in game project + * interface MyGameIntent extends IIntent { + * readonly type: 'unit.move' | 'unit.attack'; + * unitId: string; + * } + * + * // 创建收集器时提供键提取器 | Provide key extractor when creating collector + * const collector = new IntentCollector('player1', { + * keyExtractor: (intent) => `${intent.type}:${intent.unitId}` + * }); + * + * collector.addIntent({ type: 'unit.move', unitId: 'unit1' }); + * ``` + */ +export class IntentCollector implements IIntentCollector { + /** + * @zh 玩家 ID + * @en Player ID + */ + private readonly _playerId: string; + + /** + * @zh 当前 tick + * @en Current tick + */ + private _currentTick: number = 0; + + /** + * @zh 收集的意图列表 + * @en Collected intents list + */ + private _intents: T[] = []; + + /** + * @zh 按类型索引的意图 + * @en Intents indexed by type + */ + private _intentsByType: Map = new Map(); + + /** + * @zh 已添加的意图键(防止重复) + * @en Added intent keys (prevent duplicates) + */ + private _intentKeys: Set = new Set(); + + /** + * @zh 意图键提取器 + * @en Intent key extractor + */ + private readonly _keyExtractor: IntentKeyExtractor; + + /** + * @param playerId - @zh 玩家 ID @en Player ID + * @param options - @zh 配置选项 @en Configuration options + */ + constructor( + playerId: string, + options: { + keyExtractor?: IntentKeyExtractor; + } = {} + ) { + this._playerId = playerId; + this._keyExtractor = options.keyExtractor ?? (defaultIntentKeyExtractor as IntentKeyExtractor); + } + + /** + * @zh 获取意图数量 + * @en Get intent count + */ + get count(): number { + return this._intents.length; + } + + /** + * @zh 获取玩家 ID + * @en Get player ID + */ + get playerId(): string { + return this._playerId; + } + + /** + * @zh 设置当前 tick + * @en Set current tick + */ + setTick(tick: number): void { + this._currentTick = tick; + } + + /** + * @zh 添加一个意图 + * @en Add an intent + * + * @param intent - @zh 要添加的意图 @en Intent to add + * @returns @zh 是否添加成功(重复意图返回 false)@en Whether added successfully (duplicate returns false) + */ + addIntent(intent: T): boolean { + const key = this._keyExtractor(intent); + + if (this._intentKeys.has(key)) { + return false; + } + + // 添加元数据 | Add metadata + const intentWithMeta: T = { + ...intent, + playerId: this._playerId, + tick: this._currentTick + }; + + this._intents.push(intentWithMeta); + this._intentKeys.add(key); + + // 按类型索引 | Index by type + if (!this._intentsByType.has(intent.type)) { + this._intentsByType.set(intent.type, []); + } + this._intentsByType.get(intent.type)!.push(intentWithMeta); + + return true; + } + + /** + * @zh 获取所有收集的意图 + * @en Get all collected intents + */ + getIntents(): T[] { + return [...this._intents]; + } + + /** + * @zh 按类型获取意图 + * @en Get intents by type + */ + getIntentsByType(type: string): T[] { + return [...(this._intentsByType.get(type) ?? [])]; + } + + /** + * @zh 检查是否已有指定键的意图 + * @en Check if intent with specified key exists + */ + hasIntentKey(key: string): boolean { + return this._intentKeys.has(key); + } + + /** + * @zh 清除所有意图 + * @en Clear all intents + */ + clear(): void { + this._intents = []; + this._intentsByType.clear(); + this._intentKeys.clear(); + } +} diff --git a/packages/script-runtime/src/intent/IntentTypes.ts b/packages/script-runtime/src/intent/IntentTypes.ts new file mode 100644 index 00000000..85bec01c --- /dev/null +++ b/packages/script-runtime/src/intent/IntentTypes.ts @@ -0,0 +1,138 @@ +/** + * @zh 意图类型定义(引擎基础接口) + * @en Intent type definitions (engine base interface) + * + * @zh 引擎只提供基础的 IIntent 接口,具体的意图类型由游戏项目定义 + * @en Engine only provides base IIntent interface, specific intent types are defined by game projects + */ + +// ============================================================================= +// 基础意图接口 | Base Intent Interface +// ============================================================================= + +/** + * @zh 基础意图接口 + * @en Base intent interface + * + * @zh 所有游戏意图都应该继承这个接口 + * @en All game intents should extend this interface + * + * @example + * ```typescript + * // 游戏项目中定义具体意图 | Define specific intents in game project + * interface UnitMoveIntent extends IIntent { + * readonly type: 'unit.move'; + * unitId: string; + * direction: number; + * } + * ``` + */ +export interface IIntent { + /** + * @zh 意图类型(唯一标识符) + * @en Intent type (unique identifier) + */ + readonly type: string; + + /** + * @zh 发起者玩家 ID(由 IntentCollector 自动填充) + * @en Originator player ID (auto-filled by IntentCollector) + */ + playerId?: string; + + /** + * @zh 产生时的 tick(由 IntentCollector 自动填充) + * @en Tick when generated (auto-filled by IntentCollector) + */ + tick?: number; +} + +// ============================================================================= +// 意图键提取器 | Intent Key Extractor +// ============================================================================= + +/** + * @zh 意图键提取器函数类型 + * @en Intent key extractor function type + * + * @zh 用于从意图中提取唯一键,防止同一对象重复操作 + * @en Used to extract unique key from intent, preventing duplicate operations on same object + */ +export type IntentKeyExtractor = (intent: T) => string; + +/** + * @zh 默认的意图键提取器(只使用 type) + * @en Default intent key extractor (uses type only) + */ +export const defaultIntentKeyExtractor: IntentKeyExtractor = (intent) => intent.type; + +// ============================================================================= +// 通用结果常量 | Common Result Constants +// ============================================================================= + +/** + * @zh 操作成功 + * @en Operation successful + */ +export const OK = 0; + +/** + * @zh 通用错误 + * @en Generic error + */ +export const ERR_GENERIC = -1; + +/** + * @zh 不是所有者 + * @en Not the owner + */ +export const ERR_NOT_OWNER = -2; + +/** + * @zh 目标无效 + * @en Invalid target + */ +export const ERR_INVALID_TARGET = -3; + +/** + * @zh 不在范围内 + * @en Not in range + */ +export const ERR_NOT_IN_RANGE = -4; + +/** + * @zh 资源不足 + * @en Not enough resources + */ +export const ERR_NOT_ENOUGH_RESOURCES = -5; + +/** + * @zh 正忙 + * @en Currently busy + */ +export const ERR_BUSY = -6; + +/** + * @zh 参数无效 + * @en Invalid arguments + */ +export const ERR_INVALID_ARGS = -7; + +// ============================================================================= +// 通用方向常量 | Common Direction Constants +// ============================================================================= + +export const TOP = 1; +export const TOP_RIGHT = 2; +export const RIGHT = 3; +export const BOTTOM_RIGHT = 4; +export const BOTTOM = 5; +export const BOTTOM_LEFT = 6; +export const LEFT = 7; +export const TOP_LEFT = 8; + +/** + * @zh 方向类型 + * @en Direction type + */ +export type Direction = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; diff --git a/packages/script-runtime/src/nodes/GameInfoNodes.ts b/packages/script-runtime/src/nodes/GameInfoNodes.ts new file mode 100644 index 00000000..353d39a5 --- /dev/null +++ b/packages/script-runtime/src/nodes/GameInfoNodes.ts @@ -0,0 +1,201 @@ +/** + * @zh 游戏信息节点 + * @en Game Info Nodes + * + * @zh 提供获取游戏状态信息的能力 + * @en Provides access to game state information + */ + +import type { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint'; +import type { INodeExecutor, ExecutionResult } from '@esengine/blueprint'; +import type { IGameState } from '../vm/ServerExecutionContext'; + +// ============================================================================= +// 扩展的执行上下文接口 | Extended Execution Context Interface +// ============================================================================= + +interface ServerContext { + gameState: IGameState | null; + playerId: string; + deltaTime: number; +} + +// ============================================================================= +// GetTick Node | 获取 Tick 节点 +// ============================================================================= + +/** + * @zh 获取 Tick 节点模板 + * @en Get Tick node template + */ +export const GetTickTemplate: BlueprintNodeTemplate = { + type: 'GetTick', + title: 'Get Tick', + category: 'time', + description: 'Get the current game tick / 获取当前游戏 tick', + keywords: ['tick', 'time', 'frame', 'turn'], + menuPath: ['Game', 'Get Tick'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'tick', + displayName: 'Tick', + type: 'int' + } + ], + color: '#1e6b8b' +}; + +/** + * @zh 获取 Tick 节点执行器 + * @en Get Tick node executor + */ +export class GetTickExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const tick = ctx.gameState?.tick ?? 0; + + return { + outputs: { tick } + }; + } +} + +// ============================================================================= +// GetPlayerId Node | 获取玩家 ID 节点 +// ============================================================================= + +/** + * @zh 获取玩家 ID 节点模板 + * @en Get Player ID node template + */ +export const GetPlayerIdTemplate: BlueprintNodeTemplate = { + type: 'GetPlayerId', + title: 'Get Player ID', + category: 'entity', + description: 'Get the current player ID / 获取当前玩家 ID', + keywords: ['player', 'id', 'owner', 'me'], + menuPath: ['Game', 'Get Player ID'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'playerId', + displayName: 'Player ID', + type: 'string' + } + ], + color: '#1e5a8b' +}; + +/** + * @zh 获取玩家 ID 节点执行器 + * @en Get Player ID node executor + */ +export class GetPlayerIdExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + + return { + outputs: { playerId: ctx.playerId } + }; + } +} + +// ============================================================================= +// GetDeltaTime Node | 获取增量时间节点 +// ============================================================================= + +/** + * @zh 获取增量时间节点模板 + * @en Get Delta Time node template + */ +export const GetDeltaTimeTemplate: BlueprintNodeTemplate = { + type: 'GetDeltaTime', + title: 'Get Delta Time', + category: 'time', + description: 'Get the time since last tick (seconds) / 获取距上次 tick 的时间(秒)', + keywords: ['delta', 'time', 'dt', 'interval'], + menuPath: ['Game', 'Get Delta Time'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'deltaTime', + displayName: 'Delta Time', + type: 'float' + } + ], + color: '#1e6b8b' +}; + +/** + * @zh 获取增量时间节点执行器 + * @en Get Delta Time node executor + */ +export class GetDeltaTimeExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + + return { + outputs: { deltaTime: ctx.deltaTime } + }; + } +} + +// ============================================================================= +// GetGameState Node | 获取游戏状态节点 +// ============================================================================= + +/** + * @zh 获取游戏状态节点模板 + * @en Get Game State node template + */ +export const GetGameStateTemplate: BlueprintNodeTemplate = { + type: 'GetGameState', + title: 'Get Game State', + category: 'entity', + description: 'Get the current game state object / 获取当前游戏状态对象', + keywords: ['game', 'state', 'world', 'data'], + menuPath: ['Game', 'Get Game State'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'state', + displayName: 'State', + type: 'object' + } + ], + color: '#1e5a8b' +}; + +/** + * @zh 获取游戏状态节点执行器 + * @en Get Game State node executor + */ +export class GetGameStateExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + + return { + outputs: { state: ctx.gameState } + }; + } +} + +// ============================================================================= +// 节点定义集合 | Node Definition Collection +// ============================================================================= + +/** + * @zh 游戏信息节点定义 + * @en Game info node definitions + */ +export const GameInfoNodeDefinitions = [ + { template: GetTickTemplate, executor: new GetTickExecutor() }, + { template: GetPlayerIdTemplate, executor: new GetPlayerIdExecutor() }, + { template: GetDeltaTimeTemplate, executor: new GetDeltaTimeExecutor() }, + { template: GetGameStateTemplate, executor: new GetGameStateExecutor() } +]; diff --git a/packages/script-runtime/src/nodes/LogNodes.ts b/packages/script-runtime/src/nodes/LogNodes.ts new file mode 100644 index 00000000..1db212d4 --- /dev/null +++ b/packages/script-runtime/src/nodes/LogNodes.ts @@ -0,0 +1,200 @@ +/** + * @zh 日志节点 + * @en Log Nodes + * + * @zh 提供日志记录能力 + * @en Provides logging capabilities + */ + +import type { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint'; +import type { INodeExecutor, ExecutionResult } from '@esengine/blueprint'; + +// ============================================================================= +// 扩展的执行上下文接口 | Extended Execution Context Interface +// ============================================================================= + +interface ServerContext { + evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +} + +// ============================================================================= +// Log Node | 日志节点 +// ============================================================================= + +/** + * @zh 日志节点模板 + * @en Log node template + */ +export const LogTemplate: BlueprintNodeTemplate = { + type: 'Log', + title: 'Log', + category: 'debug', + description: 'Log a message / 记录日志消息', + keywords: ['log', 'print', 'debug', 'console'], + menuPath: ['Debug', 'Log'], + inputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + }, + { + name: 'message', + displayName: 'Message', + type: 'string', + defaultValue: '' + } + ], + outputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + } + ], + color: '#5a5a5a' +}; + +/** + * @zh 日志节点执行器 + * @en Log node executor + */ +export class LogExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const message = ctx.evaluateInput(node.id, 'message', '') as string; + + ctx.log(String(message)); + + return { + nextExec: 'exec' + }; + } +} + +// ============================================================================= +// Warn Node | 警告节点 +// ============================================================================= + +/** + * @zh 警告节点模板 + * @en Warn node template + */ +export const WarnTemplate: BlueprintNodeTemplate = { + type: 'Warn', + title: 'Warn', + category: 'debug', + description: 'Log a warning message / 记录警告消息', + keywords: ['warn', 'warning', 'debug', 'console'], + menuPath: ['Debug', 'Warn'], + inputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + }, + { + name: 'message', + displayName: 'Message', + type: 'string', + defaultValue: '' + } + ], + outputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + } + ], + color: '#8b8b1e' +}; + +/** + * @zh 警告节点执行器 + * @en Warn node executor + */ +export class WarnExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const message = ctx.evaluateInput(node.id, 'message', '') as string; + + ctx.warn(String(message)); + + return { + nextExec: 'exec' + }; + } +} + +// ============================================================================= +// Error Node | 错误节点 +// ============================================================================= + +/** + * @zh 错误节点模板 + * @en Error node template + */ +export const ErrorTemplate: BlueprintNodeTemplate = { + type: 'Error', + title: 'Error', + category: 'debug', + description: 'Log an error message / 记录错误消息', + keywords: ['error', 'debug', 'console'], + menuPath: ['Debug', 'Error'], + inputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + }, + { + name: 'message', + displayName: 'Message', + type: 'string', + defaultValue: '' + } + ], + outputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + } + ], + color: '#8b1e1e' +}; + +/** + * @zh 错误节点执行器 + * @en Error node executor + */ +export class ErrorExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const message = ctx.evaluateInput(node.id, 'message', '') as string; + + ctx.error(String(message)); + + return { + nextExec: 'exec' + }; + } +} + +// ============================================================================= +// 节点定义集合 | Node Definition Collection +// ============================================================================= + +/** + * @zh 日志节点定义 + * @en Log node definitions + */ +export const LogNodeDefinitions = [ + { template: LogTemplate, executor: new LogExecutor() }, + { template: WarnTemplate, executor: new WarnExecutor() }, + { template: ErrorTemplate, executor: new ErrorExecutor() } +]; diff --git a/packages/script-runtime/src/nodes/MemoryNodes.ts b/packages/script-runtime/src/nodes/MemoryNodes.ts new file mode 100644 index 00000000..4fc4a079 --- /dev/null +++ b/packages/script-runtime/src/nodes/MemoryNodes.ts @@ -0,0 +1,269 @@ +/** + * @zh Memory 操作节点 + * @en Memory Operation Nodes + * + * @zh 提供玩家持久化数据的读写能力 + * @en Provides read/write access to player persistent data + */ + +import type { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint'; +import type { INodeExecutor, ExecutionResult } from '@esengine/blueprint'; + +// ============================================================================= +// 扩展的执行上下文接口 | Extended Execution Context Interface +// ============================================================================= + +/** + * @zh 服务器端执行上下文接口(用于节点) + * @en Server-side execution context interface (for nodes) + */ +interface ServerContext { + memory: Record; + evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown; + setOutputs(nodeId: string, outputs: Record): void; +} + +// ============================================================================= +// GetMemory Node | 获取 Memory 节点 +// ============================================================================= + +/** + * @zh 获取 Memory 节点模板 + * @en Get Memory node template + */ +export const GetMemoryTemplate: BlueprintNodeTemplate = { + type: 'GetMemory', + title: 'Get Memory', + category: 'variable', + description: 'Get a value from player Memory / 从玩家 Memory 获取值', + keywords: ['memory', 'get', 'read', 'load', 'data'], + menuPath: ['Memory', 'Get Memory'], + isPure: true, + inputs: [ + { + name: 'key', + displayName: 'Key', + type: 'string', + defaultValue: '' + }, + { + name: 'defaultValue', + displayName: 'Default', + type: 'any', + defaultValue: null + } + ], + outputs: [ + { + name: 'value', + displayName: 'Value', + type: 'any' + } + ], + color: '#8b5a8b' +}; + +/** + * @zh 获取 Memory 节点执行器 + * @en Get Memory node executor + */ +export class GetMemoryExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const key = ctx.evaluateInput(node.id, 'key', '') as string; + const defaultValue = ctx.evaluateInput(node.id, 'defaultValue', null); + + const value = ctx.memory[key] ?? defaultValue; + + return { + outputs: { value } + }; + } +} + +// ============================================================================= +// SetMemory Node | 设置 Memory 节点 +// ============================================================================= + +/** + * @zh 设置 Memory 节点模板 + * @en Set Memory node template + */ +export const SetMemoryTemplate: BlueprintNodeTemplate = { + type: 'SetMemory', + title: 'Set Memory', + category: 'variable', + description: 'Set a value in player Memory / 在玩家 Memory 中设置值', + keywords: ['memory', 'set', 'write', 'save', 'data'], + menuPath: ['Memory', 'Set Memory'], + inputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + }, + { + name: 'key', + displayName: 'Key', + type: 'string', + defaultValue: '' + }, + { + name: 'value', + displayName: 'Value', + type: 'any', + defaultValue: null + } + ], + outputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + } + ], + color: '#8b5a8b' +}; + +/** + * @zh 设置 Memory 节点执行器 + * @en Set Memory node executor + */ +export class SetMemoryExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const key = ctx.evaluateInput(node.id, 'key', '') as string; + const value = ctx.evaluateInput(node.id, 'value', null); + + if (key) { + ctx.memory[key] = value; + } + + return { + nextExec: 'exec' + }; + } +} + +// ============================================================================= +// HasMemoryKey Node | 检查 Memory 键节点 +// ============================================================================= + +/** + * @zh 检查 Memory 键节点模板 + * @en Has Memory Key node template + */ +export const HasMemoryKeyTemplate: BlueprintNodeTemplate = { + type: 'HasMemoryKey', + title: 'Has Memory Key', + category: 'variable', + description: 'Check if a key exists in Memory / 检查 Memory 中是否存在某个键', + keywords: ['memory', 'has', 'exists', 'check', 'key'], + menuPath: ['Memory', 'Has Key'], + isPure: true, + inputs: [ + { + name: 'key', + displayName: 'Key', + type: 'string', + defaultValue: '' + } + ], + outputs: [ + { + name: 'exists', + displayName: 'Exists', + type: 'bool' + } + ], + color: '#8b5a8b' +}; + +/** + * @zh 检查 Memory 键节点执行器 + * @en Has Memory Key node executor + */ +export class HasMemoryKeyExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const key = ctx.evaluateInput(node.id, 'key', '') as string; + + const exists = key in ctx.memory; + + return { + outputs: { exists } + }; + } +} + +// ============================================================================= +// DeleteMemory Node | 删除 Memory 节点 +// ============================================================================= + +/** + * @zh 删除 Memory 节点模板 + * @en Delete Memory node template + */ +export const DeleteMemoryTemplate: BlueprintNodeTemplate = { + type: 'DeleteMemory', + title: 'Delete Memory', + category: 'variable', + description: 'Delete a key from Memory / 从 Memory 中删除键', + keywords: ['memory', 'delete', 'remove', 'clear', 'key'], + menuPath: ['Memory', 'Delete'], + inputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + }, + { + name: 'key', + displayName: 'Key', + type: 'string', + defaultValue: '' + } + ], + outputs: [ + { + name: 'exec', + displayName: '', + type: 'exec' + } + ], + color: '#8b5a8b' +}; + +/** + * @zh 删除 Memory 节点执行器 + * @en Delete Memory node executor + */ +export class DeleteMemoryExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ServerContext; + const key = ctx.evaluateInput(node.id, 'key', '') as string; + + if (key) { + delete ctx.memory[key]; + } + + return { + nextExec: 'exec' + }; + } +} + +// ============================================================================= +// 节点定义集合 | Node Definition Collection +// ============================================================================= + +/** + * @zh Memory 节点定义 + * @en Memory node definitions + */ +export const MemoryNodeDefinitions = [ + { template: GetMemoryTemplate, executor: new GetMemoryExecutor() }, + { template: SetMemoryTemplate, executor: new SetMemoryExecutor() }, + { template: HasMemoryKeyTemplate, executor: new HasMemoryKeyExecutor() }, + { template: DeleteMemoryTemplate, executor: new DeleteMemoryExecutor() } +]; diff --git a/packages/script-runtime/src/nodes/index.ts b/packages/script-runtime/src/nodes/index.ts new file mode 100644 index 00000000..24c7be27 --- /dev/null +++ b/packages/script-runtime/src/nodes/index.ts @@ -0,0 +1,84 @@ +/** + * @zh 蓝图节点模块 + * @en Blueprint Nodes Module + * + * @zh 提供服务器端蓝图执行所需的通用节点 + * @en Provides common nodes for server-side blueprint execution + */ + +import { NodeRegistry } from '@esengine/blueprint'; + +// Memory Nodes +export { + GetMemoryTemplate, + GetMemoryExecutor, + SetMemoryTemplate, + SetMemoryExecutor, + HasMemoryKeyTemplate, + HasMemoryKeyExecutor, + DeleteMemoryTemplate, + DeleteMemoryExecutor, + MemoryNodeDefinitions +} from './MemoryNodes'; + +// Log Nodes +export { + LogTemplate, + LogExecutor, + WarnTemplate, + WarnExecutor, + ErrorTemplate, + ErrorExecutor, + LogNodeDefinitions +} from './LogNodes'; + +// Game Info Nodes +export { + GetTickTemplate, + GetTickExecutor, + GetPlayerIdTemplate, + GetPlayerIdExecutor, + GetDeltaTimeTemplate, + GetDeltaTimeExecutor, + GetGameStateTemplate, + GetGameStateExecutor, + GameInfoNodeDefinitions +} from './GameInfoNodes'; + +// ============================================================================= +// 节点注册 | Node Registration +// ============================================================================= + +import { MemoryNodeDefinitions } from './MemoryNodes'; +import { LogNodeDefinitions } from './LogNodes'; +import { GameInfoNodeDefinitions } from './GameInfoNodes'; + +/** + * @zh 所有节点定义 + * @en All node definitions + */ +export const AllNodeDefinitions = [ + ...MemoryNodeDefinitions, + ...LogNodeDefinitions, + ...GameInfoNodeDefinitions +]; + +/** + * @zh 注册所有 script-runtime 节点到 NodeRegistry + * @en Register all script-runtime nodes to NodeRegistry + * + * @example + * ```typescript + * import { registerScriptRuntimeNodes } from '@esengine/script-runtime'; + * + * // 在应用启动时调用 | Call at application startup + * registerScriptRuntimeNodes(); + * ``` + */ +export function registerScriptRuntimeNodes(): void { + const registry = NodeRegistry.instance; + + for (const { template, executor } of AllNodeDefinitions) { + registry.register(template, executor); + } +} diff --git a/packages/script-runtime/src/persistence/FileMemoryStore.ts b/packages/script-runtime/src/persistence/FileMemoryStore.ts new file mode 100644 index 00000000..06b80d82 --- /dev/null +++ b/packages/script-runtime/src/persistence/FileMemoryStore.ts @@ -0,0 +1,339 @@ +/** + * @zh 文件系统 Memory 存储 + * @en File System Memory Store + * + * @zh 使用文件系统存储玩家 Memory 和世界状态 + * @en Uses file system to store player Memory and world state + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { + IMemoryStore, + PlayerMemory, + WorldState, + MemoryStoreStats +} from './IMemoryStore'; + +/** + * @zh 文件存储配置 + * @en File storage configuration + */ +export interface FileMemoryStoreConfig { + /** + * @zh 存储根目录 + * @en Storage root directory + */ + basePath: string; + + /** + * @zh 是否美化 JSON 输出 + * @en Whether to prettify JSON output + */ + prettyPrint?: boolean; + + /** + * @zh Memory 大小限制(字节) + * @en Memory size limit (bytes) + */ + memorySizeLimit?: number; +} + +/** + * @zh 默认配置 + * @en Default configuration + */ +const DEFAULT_CONFIG: Required = { + basePath: './data', + prettyPrint: true, + memorySizeLimit: 2 * 1024 * 1024 // 2MB +}; + +/** + * @zh 文件系统 Memory 存储 + * @en File System Memory Store + * + * @zh 简单的文件存储实现,适合开发和小规模部署 + * @en Simple file storage implementation, suitable for development and small deployments + * + * @example + * ```typescript + * const store = new FileMemoryStore({ basePath: './game-data' }); + * await store.init(); + * + * // 保存玩家 Memory | Save player Memory + * await store.savePlayerMemory('player1', { creeps: {} }); + * + * // 加载玩家 Memory | Load player Memory + * const memory = await store.loadPlayerMemory('player1'); + * ``` + */ +export class FileMemoryStore implements IMemoryStore { + /** + * @zh 配置 + * @en Configuration + */ + private readonly _config: Required; + + /** + * @zh Memory 目录路径 + * @en Memory directory path + */ + private readonly _memoryPath: string; + + /** + * @zh 世界状态文件路径 + * @en World state file path + */ + private readonly _worldPath: string; + + /** + * @zh 是否已初始化 + * @en Whether initialized + */ + private _initialized: boolean = false; + + constructor(config: Partial = {}) { + this._config = { ...DEFAULT_CONFIG, ...config }; + this._memoryPath = path.join(this._config.basePath, 'memory'); + this._worldPath = path.join(this._config.basePath, 'world.json'); + } + + /** + * @zh 初始化存储(创建目录) + * @en Initialize storage (create directories) + */ + async init(): Promise { + if (this._initialized) return; + + try { + await fs.mkdir(this._memoryPath, { recursive: true }); + this._initialized = true; + } catch (error) { + throw new Error(`Failed to initialize storage: ${error}`); + } + } + + /** + * @zh 确保已初始化 + * @en Ensure initialized + */ + private async _ensureInit(): Promise { + if (!this._initialized) { + await this.init(); + } + } + + /** + * @zh 获取玩家 Memory 文件路径 + * @en Get player Memory file path + */ + private _getPlayerMemoryPath(playerId: string): string { + // 清理 playerId 以防止路径遍历 | Sanitize playerId to prevent path traversal + const safeId = playerId.replace(/[^a-zA-Z0-9_-]/g, '_'); + return path.join(this._memoryPath, `${safeId}.json`); + } + + /** + * @zh 加载玩家 Memory + * @en Load player Memory + */ + async loadPlayerMemory(playerId: string): Promise { + await this._ensureInit(); + + const filePath = this._getPlayerMemoryPath(playerId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content) as PlayerMemory; + } catch (error) { + // 文件不存在则返回空对象 | Return empty object if file doesn't exist + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw new Error(`Failed to load Memory for ${playerId}: ${error}`); + } + } + + /** + * @zh 保存玩家 Memory + * @en Save player Memory + */ + async savePlayerMemory(playerId: string, memory: PlayerMemory): Promise { + await this._ensureInit(); + + const content = this._config.prettyPrint + ? JSON.stringify(memory, null, 2) + : JSON.stringify(memory); + + // 检查大小限制 | Check size limit + if (content.length > this._config.memorySizeLimit) { + throw new Error( + `Memory size (${content.length} bytes) exceeds limit ` + + `(${this._config.memorySizeLimit} bytes) for player ${playerId}` + ); + } + + const filePath = this._getPlayerMemoryPath(playerId); + + try { + await fs.writeFile(filePath, content, 'utf-8'); + } catch (error) { + throw new Error(`Failed to save Memory for ${playerId}: ${error}`); + } + } + + /** + * @zh 批量保存玩家 Memory + * @en Batch save player Memory + */ + async savePlayerMemoryBatch( + entries: Array<{ playerId: string; memory: PlayerMemory }> + ): Promise { + await this._ensureInit(); + + // 并行保存所有玩家 | Save all players in parallel + await Promise.all( + entries.map(({ playerId, memory }) => + this.savePlayerMemory(playerId, memory) + ) + ); + } + + /** + * @zh 删除玩家 Memory + * @en Delete player Memory + */ + async deletePlayerMemory(playerId: string): Promise { + await this._ensureInit(); + + const filePath = this._getPlayerMemoryPath(playerId); + + try { + await fs.unlink(filePath); + } catch (error) { + // 文件不存在则忽略 | Ignore if file doesn't exist + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`Failed to delete Memory for ${playerId}: ${error}`); + } + } + } + + /** + * @zh 获取所有玩家 ID + * @en Get all player IDs + */ + async getAllPlayerIds(): Promise { + await this._ensureInit(); + + try { + const files = await fs.readdir(this._memoryPath); + return files + .filter(f => f.endsWith('.json')) + .map(f => f.slice(0, -5)); // 移除 .json 后缀 | Remove .json suffix + } catch (error) { + return []; + } + } + + /** + * @zh 加载世界状态 + * @en Load world state + */ + async loadWorldState(): Promise { + await this._ensureInit(); + + try { + const content = await fs.readFile(this._worldPath, 'utf-8'); + return JSON.parse(content) as WorldState; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw new Error(`Failed to load world state: ${error}`); + } + } + + /** + * @zh 保存世界状态 + * @en Save world state + */ + async saveWorldState(state: WorldState): Promise { + await this._ensureInit(); + + const stateWithTimestamp: WorldState = { + ...state, + lastSaveTime: Date.now() + }; + + const content = this._config.prettyPrint + ? JSON.stringify(stateWithTimestamp, null, 2) + : JSON.stringify(stateWithTimestamp); + + try { + await fs.writeFile(this._worldPath, content, 'utf-8'); + } catch (error) { + throw new Error(`Failed to save world state: ${error}`); + } + } + + /** + * @zh 获取存储统计信息 + * @en Get storage statistics + */ + async getStats(): Promise { + await this._ensureInit(); + + const playerIds = await this.getAllPlayerIds(); + let totalSize = 0; + let lastSaveTime = 0; + + for (const playerId of playerIds) { + try { + const filePath = this._getPlayerMemoryPath(playerId); + const stats = await fs.stat(filePath); + totalSize += stats.size; + if (stats.mtimeMs > lastSaveTime) { + lastSaveTime = stats.mtimeMs; + } + } catch { + // 忽略错误 | Ignore errors + } + } + + // 加上世界状态文件大小 | Add world state file size + try { + const worldStats = await fs.stat(this._worldPath); + totalSize += worldStats.size; + if (worldStats.mtimeMs > lastSaveTime) { + lastSaveTime = worldStats.mtimeMs; + } + } catch { + // 忽略错误 | Ignore errors + } + + return { + playerCount: playerIds.length, + totalSize, + lastSaveTime + }; + } + + /** + * @zh 清除所有数据(慎用!) + * @en Clear all data (use with caution!) + */ + async clearAll(): Promise { + await this._ensureInit(); + + const playerIds = await this.getAllPlayerIds(); + await Promise.all(playerIds.map(id => this.deletePlayerMemory(id))); + + try { + await fs.unlink(this._worldPath); + } catch { + // 忽略错误 | Ignore errors + } + } +} diff --git a/packages/script-runtime/src/persistence/IMemoryStore.ts b/packages/script-runtime/src/persistence/IMemoryStore.ts new file mode 100644 index 00000000..f604b140 --- /dev/null +++ b/packages/script-runtime/src/persistence/IMemoryStore.ts @@ -0,0 +1,131 @@ +/** + * @zh Memory 存储接口 + * @en Memory Store Interface + * + * @zh 定义玩家 Memory 和世界状态的持久化接口 + * @en Defines persistence interface for player Memory and world state + */ + +/** + * @zh 玩家 Memory 类型 + * @en Player Memory type + */ +export type PlayerMemory = Record; + +/** + * @zh 世界状态类型 + * @en World state type + */ +export interface WorldState { + /** + * @zh 当前 tick + * @en Current tick + */ + tick: number; + + /** + * @zh 上次保存时间 + * @en Last save time + */ + lastSaveTime: number; + + /** + * @zh 实体状态 + * @en Entity states + */ + entities: Record; + + /** + * @zh 房间状态 + * @en Room states + */ + rooms: Record; +} + +/** + * @zh Memory 存储接口 + * @en Memory store interface + */ +export interface IMemoryStore { + /** + * @zh 加载玩家 Memory + * @en Load player Memory + * + * @param playerId - @zh 玩家 ID @en Player ID + * @returns @zh 玩家 Memory @en Player Memory + */ + loadPlayerMemory(playerId: string): Promise; + + /** + * @zh 保存玩家 Memory + * @en Save player Memory + * + * @param playerId - @zh 玩家 ID @en Player ID + * @param memory - @zh Memory 数据 @en Memory data + */ + savePlayerMemory(playerId: string, memory: PlayerMemory): Promise; + + /** + * @zh 批量保存玩家 Memory + * @en Batch save player Memory + * + * @param entries - @zh Memory 条目列表 @en Memory entry list + */ + savePlayerMemoryBatch(entries: Array<{ playerId: string; memory: PlayerMemory }>): Promise; + + /** + * @zh 删除玩家 Memory + * @en Delete player Memory + * + * @param playerId - @zh 玩家 ID @en Player ID + */ + deletePlayerMemory(playerId: string): Promise; + + /** + * @zh 获取所有玩家 ID + * @en Get all player IDs + */ + getAllPlayerIds(): Promise; + + /** + * @zh 加载世界状态 + * @en Load world state + */ + loadWorldState(): Promise; + + /** + * @zh 保存世界状态 + * @en Save world state + */ + saveWorldState(state: WorldState): Promise; + + /** + * @zh 获取存储统计信息 + * @en Get storage statistics + */ + getStats(): Promise; +} + +/** + * @zh 存储统计信息 + * @en Storage statistics + */ +export interface MemoryStoreStats { + /** + * @zh 玩家数量 + * @en Player count + */ + playerCount: number; + + /** + * @zh 总存储大小(字节) + * @en Total storage size (bytes) + */ + totalSize: number; + + /** + * @zh 上次保存时间 + * @en Last save time + */ + lastSaveTime: number; +} diff --git a/packages/script-runtime/src/server/GameLoop.ts b/packages/script-runtime/src/server/GameLoop.ts new file mode 100644 index 00000000..b440e6c6 --- /dev/null +++ b/packages/script-runtime/src/server/GameLoop.ts @@ -0,0 +1,368 @@ +/** + * @zh 游戏主循环 + * @en Game Main Loop + * + * @zh 协调玩家蓝图执行、意图处理和状态更新 + * @en Coordinates player blueprint execution, intent processing, and state updates + */ + +import type { IIntent } from '../intent/IntentTypes'; +import type { IGameState } from '../vm/ServerExecutionContext'; +import type { IMemoryStore } from '../persistence/IMemoryStore'; +import type { TickScheduler } from './TickScheduler'; +import type { IIntentProcessor } from './IIntentProcessor'; +import type { + GameLoopConfig, + GameLoopState, + GameLoopEvents, + TickExecutionResult, + IntentProcessingResult +} from './types'; + +/** + * @zh 默认游戏循环配置 + * @en Default game loop configuration + */ +export const DEFAULT_GAME_LOOP_CONFIG: Required = { + tickInterval: 1000, + maxCatchUpTicks: 5, + autoSaveMemory: true, + memorySaveInterval: 10 +}; + +/** + * @zh 游戏循环统计 + * @en Game loop statistics + */ +export interface GameLoopStats { + readonly currentTick: number; + readonly state: GameLoopState; + readonly totalTicksProcessed: number; + readonly averageTickDuration: number; + readonly lastTickDuration: number; + readonly skippedTicks: number; +} + +/** + * @zh 游戏主循环 + * @en Game Main Loop + * + * @zh 完整的游戏循环实现,协调各组件工作 + * @en Complete game loop implementation, coordinating all components + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * const gameLoop = new GameLoop({ + * scheduler, + * intentProcessor, + * memoryStore, + * getGameState: () => gameWorld.getState(), + * updateGameState: (state) => gameWorld.setState(state), + * config: { tickInterval: 1000 } + * }); + * + * // 启动游戏循环 + * await gameLoop.start(); + * + * // 稍后停止 + * await gameLoop.stop(); + * ``` + */ +export class GameLoop< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + private readonly _scheduler: TickScheduler; + private readonly _intentProcessor: IIntentProcessor; + private readonly _memoryStore: IMemoryStore | null; + private readonly _getGameState: () => TGameState; + private readonly _updateGameState: (state: TGameState) => void; + private readonly _config: Required; + private readonly _events: GameLoopEvents; + + private _state: GameLoopState = 'idle'; + private _currentTick: number = 0; + private _timerId: ReturnType | null = null; + private _lastTickTime: number = 0; + private _totalTicksProcessed: number = 0; + private _totalTickDuration: number = 0; + private _lastTickDuration: number = 0; + private _skippedTicks: number = 0; + private _ticksSinceLastSave: number = 0; + + constructor(options: { + scheduler: TickScheduler; + intentProcessor: IIntentProcessor; + memoryStore?: IMemoryStore; + getGameState: () => TGameState; + updateGameState: (state: TGameState) => void; + config?: GameLoopConfig; + events?: GameLoopEvents; + }) { + this._scheduler = options.scheduler; + this._intentProcessor = options.intentProcessor; + this._memoryStore = options.memoryStore ?? null; + this._getGameState = options.getGameState; + this._updateGameState = options.updateGameState; + this._config = { ...DEFAULT_GAME_LOOP_CONFIG, ...options.config }; + this._events = options.events ?? {}; + } + + // ========================================================================= + // 属性 | Properties + // ========================================================================= + + /** + * @zh 获取当前状态 + * @en Get current state + */ + get state(): GameLoopState { + return this._state; + } + + /** + * @zh 获取当前 tick + * @en Get current tick + */ + get currentTick(): number { + return this._currentTick; + } + + /** + * @zh 获取调度器 + * @en Get scheduler + */ + get scheduler(): TickScheduler { + return this._scheduler; + } + + // ========================================================================= + // 生命周期 | Lifecycle + // ========================================================================= + + /** + * @zh 启动游戏循环 + * @en Start game loop + * + * @param startTick - @zh 起始 tick(可选)@en Starting tick (optional) + */ + async start(startTick?: number): Promise { + if (this._state === 'running') { + return; + } + + if (startTick !== undefined) { + this._currentTick = startTick; + } + + this._state = 'running'; + this._lastTickTime = performance.now(); + + this._scheduleNextTick(); + } + + /** + * @zh 停止游戏循环 + * @en Stop game loop + */ + async stop(): Promise { + if (this._state !== 'running' && this._state !== 'paused') { + return; + } + + this._state = 'stopping'; + + if (this._timerId !== null) { + clearTimeout(this._timerId); + this._timerId = null; + } + + if (this._config.autoSaveMemory && this._memoryStore) { + await this._saveAllMemory(); + } + + this._state = 'idle'; + } + + /** + * @zh 暂停游戏循环 + * @en Pause game loop + */ + pause(): void { + if (this._state === 'running') { + this._state = 'paused'; + if (this._timerId !== null) { + clearTimeout(this._timerId); + this._timerId = null; + } + } + } + + /** + * @zh 恢复游戏循环 + * @en Resume game loop + */ + resume(): void { + if (this._state === 'paused') { + this._state = 'running'; + this._lastTickTime = performance.now(); + this._scheduleNextTick(); + } + } + + // ========================================================================= + // 手动执行 | Manual Execution + // ========================================================================= + + /** + * @zh 手动执行单个 tick + * @en Manually execute a single tick + * + * @zh 用于测试或单步调试 + * @en For testing or step-by-step debugging + */ + async executeSingleTick(): Promise<{ + playerResults: TickExecutionResult; + intentResults: IntentProcessingResult; + }> { + const gameState = this._getGameState(); + const stateWithTick = this._withTick(gameState, this._currentTick); + + await this._events.onTickStart?.(this._currentTick); + + const playerResults = this._scheduler.executeTick(stateWithTick); + await this._events.onPlayersExecuted?.(playerResults); + + const intentResults = this._intentProcessor.process(stateWithTick, playerResults.allIntents); + await this._events.onIntentsProcessed?.(intentResults); + + this._updateGameState(intentResults.gameState); + + await this._events.onTickEnd?.(this._currentTick, intentResults.gameState); + + this._currentTick++; + + return { playerResults, intentResults }; + } + + // ========================================================================= + // 统计 | Statistics + // ========================================================================= + + /** + * @zh 获取统计信息 + * @en Get statistics + */ + getStats(): GameLoopStats { + return { + currentTick: this._currentTick, + state: this._state, + totalTicksProcessed: this._totalTicksProcessed, + averageTickDuration: this._totalTicksProcessed > 0 + ? this._totalTickDuration / this._totalTicksProcessed + : 0, + lastTickDuration: this._lastTickDuration, + skippedTicks: this._skippedTicks + }; + } + + // ========================================================================= + // 私有方法 | Private Methods + // ========================================================================= + + private _scheduleNextTick(): void { + if (this._state !== 'running') { + return; + } + + const now = performance.now(); + const elapsed = now - this._lastTickTime; + const delay = Math.max(0, this._config.tickInterval - elapsed); + + this._timerId = setTimeout(() => { + this._runTick().catch(error => { + this._events.onError?.(error, this._currentTick); + }); + }, delay); + } + + private async _runTick(): Promise { + if (this._state !== 'running') { + return; + } + + const tickStartTime = performance.now(); + + try { + await this.executeSingleTick(); + + this._lastTickDuration = performance.now() - tickStartTime; + this._totalTickDuration += this._lastTickDuration; + this._totalTicksProcessed++; + + this._ticksSinceLastSave++; + if ( + this._config.autoSaveMemory && + this._memoryStore && + this._ticksSinceLastSave >= this._config.memorySaveInterval + ) { + await this._saveAllMemory(); + this._ticksSinceLastSave = 0; + } + + this._handleCatchUp(tickStartTime); + + } catch (error) { + await this._events.onError?.( + error instanceof Error ? error : new Error(String(error)), + this._currentTick + ); + } + + this._lastTickTime = performance.now(); + this._scheduleNextTick(); + } + + private _handleCatchUp(tickStartTime: number): void { + const tickEndTime = performance.now(); + const tickDuration = tickEndTime - tickStartTime; + + if (tickDuration > this._config.tickInterval) { + const missedTicks = Math.floor(tickDuration / this._config.tickInterval); + const ticksToSkip = Math.min(missedTicks, this._config.maxCatchUpTicks); + + if (ticksToSkip > 0) { + this._skippedTicks += ticksToSkip; + this._currentTick += ticksToSkip; + } + } + } + + private async _saveAllMemory(): Promise { + if (!this._memoryStore) return; + + const entries: Array<{ playerId: string; memory: Record }> = []; + + for (const playerId of this._scheduler.playerIds) { + const memory = this._scheduler.getPlayerMemory(playerId); + if (memory) { + entries.push({ playerId, memory: memory as Record }); + } + } + + if (entries.length > 0) { + await this._memoryStore.savePlayerMemoryBatch(entries); + } + } + + private _withTick(state: TGameState, tick: number): TGameState { + return { + ...state, + tick, + deltaTime: this._config.tickInterval / 1000 + }; + } +} diff --git a/packages/script-runtime/src/server/IIntentProcessor.ts b/packages/script-runtime/src/server/IIntentProcessor.ts new file mode 100644 index 00000000..8a3c7a47 --- /dev/null +++ b/packages/script-runtime/src/server/IIntentProcessor.ts @@ -0,0 +1,248 @@ +/** + * @zh 意图处理器接口 + * @en Intent Processor Interface + * + * @zh 定义意图处理的抽象接口,由游戏项目实现 + * @en Defines abstract interface for intent processing, implemented by game projects + */ + +import type { IIntent } from '../intent/IntentTypes'; +import type { IGameState } from '../vm/ServerExecutionContext'; +import type { IntentProcessingResult } from './types'; + +/** + * @zh 意图处理器接口 + * @en Intent processor interface + * + * @zh 游戏项目实现此接口以处理玩家蓝图产生的意图 + * @en Game projects implement this interface to process intents from player blueprints + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * // 游戏项目中实现 | Implement in game project + * class MyIntentProcessor implements IIntentProcessor { + * process(gameState: MyGameState, intents: MyIntent[]): IntentProcessingResult { + * let newState = gameState; + * let processedCount = 0; + * let rejectedCount = 0; + * const errors: string[] = []; + * + * for (const intent of intents) { + * const result = this.processIntent(newState, intent); + * if (result.success) { + * newState = result.state; + * processedCount++; + * } else { + * rejectedCount++; + * errors.push(result.error); + * } + * } + * + * return { gameState: newState, processedCount, rejectedCount, errors }; + * } + * } + * ``` + */ +export interface IIntentProcessor< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + /** + * @zh 处理一批意图 + * @en Process a batch of intents + * + * @param gameState - @zh 当前游戏状态 @en Current game state + * @param intents - @zh 要处理的意图列表 @en Intents to process + * @returns @zh 处理结果 @en Processing result + */ + process(gameState: TGameState, intents: readonly TIntent[]): IntentProcessingResult; +} + +/** + * @zh 单个意图处理结果 + * @en Single intent processing result + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + */ +export type SingleIntentResult = + | { readonly success: true; readonly state: TGameState } + | { readonly success: false; readonly error: string }; + +/** + * @zh 意图处理器基类 + * @en Intent processor base class + * + * @zh 提供常用的意图处理模式 + * @en Provides common intent processing patterns + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * class MyProcessor extends IntentProcessorBase { + * protected processIntent(state: MyGameState, intent: MyIntent): SingleIntentResult { + * switch (intent.type) { + * case 'unit.move': + * return this.processUnitMove(state, intent); + * case 'unit.attack': + * return this.processUnitAttack(state, intent); + * default: + * return { success: false, error: `Unknown intent type: ${intent.type}` }; + * } + * } + * } + * ``` + */ +export abstract class IntentProcessorBase< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> implements IIntentProcessor { + /** + * @zh 处理一批意图 + * @en Process a batch of intents + */ + process(gameState: TGameState, intents: readonly TIntent[]): IntentProcessingResult { + let currentState = gameState; + let processedCount = 0; + let rejectedCount = 0; + const errors: string[] = []; + + const sortedIntents = this.sortIntents(intents); + + for (const intent of sortedIntents) { + if (!this.validateIntent(currentState, intent)) { + rejectedCount++; + errors.push(`Intent validation failed: ${intent.type}`); + continue; + } + + const result = this.processIntent(currentState, intent); + + if (result.success) { + currentState = result.state; + processedCount++; + } else { + rejectedCount++; + errors.push(result.error); + } + } + + return { + gameState: currentState, + processedCount, + rejectedCount, + errors + }; + } + + /** + * @zh 处理单个意图 + * @en Process a single intent + * + * @zh 子类必须实现此方法 + * @en Subclasses must implement this method + */ + protected abstract processIntent( + state: TGameState, + intent: TIntent + ): SingleIntentResult; + + /** + * @zh 验证意图是否有效 + * @en Validate whether intent is valid + * + * @zh 子类可以覆盖此方法添加验证逻辑 + * @en Subclasses can override this method to add validation logic + */ + protected validateIntent(_state: TGameState, _intent: TIntent): boolean { + return true; + } + + /** + * @zh 对意图进行排序 + * @en Sort intents + * + * @zh 子类可以覆盖此方法定义处理顺序 + * @en Subclasses can override this method to define processing order + */ + protected sortIntents(intents: readonly TIntent[]): readonly TIntent[] { + return intents; + } +} + +/** + * @zh 意图处理器注册表 + * @en Intent processor registry + * + * @zh 按意图类型注册处理器 + * @en Register processors by intent type + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export class IntentProcessorRegistry< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> implements IIntentProcessor { + private readonly _handlers = new Map< + string, + (state: TGameState, intent: TIntent) => SingleIntentResult + >(); + + /** + * @zh 注册意图处理器 + * @en Register intent handler + * + * @param intentType - @zh 意图类型 @en Intent type + * @param handler - @zh 处理函数 @en Handler function + */ + register( + intentType: string, + handler: (state: TGameState, intent: TIntent) => SingleIntentResult + ): this { + this._handlers.set(intentType, handler); + return this; + } + + /** + * @zh 处理一批意图 + * @en Process a batch of intents + */ + process(gameState: TGameState, intents: readonly TIntent[]): IntentProcessingResult { + let currentState = gameState; + let processedCount = 0; + let rejectedCount = 0; + const errors: string[] = []; + + for (const intent of intents) { + const handler = this._handlers.get(intent.type); + + if (!handler) { + rejectedCount++; + errors.push(`No handler for intent type: ${intent.type}`); + continue; + } + + const result = handler(currentState, intent); + + if (result.success) { + currentState = result.state; + processedCount++; + } else { + rejectedCount++; + errors.push(result.error); + } + } + + return { + gameState: currentState, + processedCount, + rejectedCount, + errors + }; + } +} diff --git a/packages/script-runtime/src/server/PlayerSession.ts b/packages/script-runtime/src/server/PlayerSession.ts new file mode 100644 index 00000000..98c89d6c --- /dev/null +++ b/packages/script-runtime/src/server/PlayerSession.ts @@ -0,0 +1,294 @@ +/** + * @zh 玩家会话 + * @en Player Session + * + * @zh 封装单个玩家的 VM 实例、蓝图和 Memory 状态 + * @en Encapsulates a single player's VM instance, blueprint and Memory state + */ + +import type { BlueprintAsset } from '@esengine/blueprint'; +import type { IIntent, IntentKeyExtractor } from '../intent/IntentTypes'; +import type { IGameState } from '../vm/ServerExecutionContext'; +import type { CPULimiterConfig } from '../vm/CPULimiter'; +import { ServerBlueprintVM } from '../vm/ServerBlueprintVM'; +import type { PlayerTickResult } from './types'; + +/** + * @zh 玩家会话配置 + * @en Player session configuration + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface PlayerSessionConfig { + /** + * @zh CPU 限制配置 + * @en CPU limit configuration + */ + readonly cpuConfig?: Partial; + + /** + * @zh 意图键提取器 + * @en Intent key extractor + */ + readonly intentKeyExtractor?: IntentKeyExtractor; + + /** + * @zh 调试模式 + * @en Debug mode + */ + readonly debug?: boolean; +} + +/** + * @zh 玩家会话状态 + * @en Player session state + */ +export type PlayerSessionState = 'active' | 'suspended' | 'error'; + +/** + * @zh 玩家会话 + * @en Player Session + * + * @zh 管理单个玩家的蓝图执行环境 + * @en Manages a single player's blueprint execution environment + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * const session = new PlayerSession( + * 'player1', + * playerBlueprint, + * { cpuConfig: { maxCpuTime: 50 } } + * ); + * + * const result = session.executeTick(gameState); + * ``` + */ +export class PlayerSession< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + private readonly _playerId: string; + private readonly _vm: ServerBlueprintVM; + private _memory: Record; + private _state: PlayerSessionState = 'active'; + private _lastError: string | null = null; + private _totalCpuUsed: number = 0; + private _ticksExecuted: number = 0; + + /** + * @param playerId - @zh 玩家 ID @en Player ID + * @param blueprint - @zh 蓝图资产 @en Blueprint asset + * @param config - @zh 配置选项 @en Configuration options + */ + constructor( + playerId: string, + blueprint: BlueprintAsset, + config: PlayerSessionConfig = {} + ) { + this._playerId = playerId; + this._memory = {}; + this._vm = new ServerBlueprintVM(playerId, blueprint, { + cpuConfig: config.cpuConfig, + intentKeyExtractor: config.intentKeyExtractor, + debug: config.debug + }); + } + + // ========================================================================= + // 属性 | Properties + // ========================================================================= + + /** + * @zh 获取玩家 ID + * @en Get player ID + */ + get playerId(): string { + return this._playerId; + } + + /** + * @zh 获取会话状态 + * @en Get session state + */ + get state(): PlayerSessionState { + return this._state; + } + + /** + * @zh 获取最后一次错误 + * @en Get last error + */ + get lastError(): string | null { + return this._lastError; + } + + /** + * @zh 获取累计 CPU 使用时间 + * @en Get total CPU time used + */ + get totalCpuUsed(): number { + return this._totalCpuUsed; + } + + /** + * @zh 获取已执行的 tick 数 + * @en Get number of ticks executed + */ + get ticksExecuted(): number { + return this._ticksExecuted; + } + + /** + * @zh 获取当前 Memory + * @en Get current Memory + */ + get memory(): Readonly> { + return this._memory; + } + + /** + * @zh 获取底层 VM 实例 + * @en Get underlying VM instance + */ + get vm(): ServerBlueprintVM { + return this._vm; + } + + // ========================================================================= + // Memory 管理 | Memory Management + // ========================================================================= + + /** + * @zh 设置 Memory + * @en Set Memory + */ + setMemory(memory: Record): void { + this._memory = { ...memory }; + } + + /** + * @zh 更新 Memory(合并) + * @en Update Memory (merge) + */ + updateMemory(updates: Record): void { + this._memory = { ...this._memory, ...updates }; + } + + /** + * @zh 清空 Memory + * @en Clear Memory + */ + clearMemory(): void { + this._memory = {}; + } + + // ========================================================================= + // 执行 | Execution + // ========================================================================= + + /** + * @zh 执行一个 tick + * @en Execute one tick + * + * @param gameState - @zh 当前游戏状态 @en Current game state + * @returns @zh 执行结果 @en Execution result + */ + executeTick(gameState: TGameState): PlayerTickResult { + if (this._state === 'suspended') { + return this._createSkippedResult('Session is suspended'); + } + + try { + const result = this._vm.executeTick(gameState, this._memory); + + this._memory = result.memory; + this._totalCpuUsed += result.cpu.used; + this._ticksExecuted++; + + if (!result.success && result.errors.length > 0) { + this._lastError = result.errors[0]; + } + + return { + playerId: this._playerId, + success: result.success, + cpu: result.cpu, + intents: result.intents, + logs: result.logs, + errors: result.errors, + memory: result.memory + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this._state = 'error'; + this._lastError = errorMessage; + + return this._createSkippedResult(errorMessage); + } + } + + // ========================================================================= + // 状态管理 | State Management + // ========================================================================= + + /** + * @zh 暂停会话 + * @en Suspend session + */ + suspend(): void { + this._state = 'suspended'; + } + + /** + * @zh 恢复会话 + * @en Resume session + */ + resume(): void { + if (this._state === 'suspended') { + this._state = 'active'; + } + } + + /** + * @zh 重置会话 + * @en Reset session + */ + reset(): void { + this._vm.reset(); + this._state = 'active'; + this._lastError = null; + this._totalCpuUsed = 0; + this._ticksExecuted = 0; + } + + /** + * @zh 从错误状态恢复 + * @en Recover from error state + */ + recover(): void { + if (this._state === 'error') { + this._vm.reset(); + this._state = 'active'; + this._lastError = null; + } + } + + // ========================================================================= + // 私有方法 | Private Methods + // ========================================================================= + + private _createSkippedResult(error: string): PlayerTickResult { + return { + playerId: this._playerId, + success: false, + cpu: { used: 0, limit: 0, bucket: 0, steps: 0, maxSteps: 0, exceeded: false }, + intents: [], + logs: [], + errors: [error], + memory: this._memory + }; + } +} diff --git a/packages/script-runtime/src/server/TickScheduler.ts b/packages/script-runtime/src/server/TickScheduler.ts new file mode 100644 index 00000000..dce29f93 --- /dev/null +++ b/packages/script-runtime/src/server/TickScheduler.ts @@ -0,0 +1,419 @@ +/** + * @zh Tick 调度器 + * @en Tick Scheduler + * + * @zh 管理所有玩家会话的执行调度 + * @en Manages execution scheduling for all player sessions + */ + +import type { BlueprintAsset } from '@esengine/blueprint'; +import type { IIntent, IntentKeyExtractor } from '../intent/IntentTypes'; +import type { IGameState } from '../vm/ServerExecutionContext'; +import type { CPULimiterConfig } from '../vm/CPULimiter'; +import { PlayerSession, type PlayerSessionConfig } from './PlayerSession'; +import type { TickExecutionResult, PlayerTickResult } from './types'; + +/** + * @zh 调度器配置 + * @en Scheduler configuration + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface TickSchedulerConfig { + /** + * @zh 默认 CPU 配置 + * @en Default CPU configuration + */ + readonly defaultCpuConfig?: Partial; + + /** + * @zh 默认意图键提取器 + * @en Default intent key extractor + */ + readonly defaultIntentKeyExtractor?: IntentKeyExtractor; + + /** + * @zh 是否并行执行玩家蓝图 + * @en Whether to execute player blueprints in parallel + * + * @default false + */ + readonly parallel?: boolean; + + /** + * @zh 调试模式 + * @en Debug mode + */ + readonly debug?: boolean; +} + +/** + * @zh Tick 调度器 + * @en Tick Scheduler + * + * @zh 负责管理玩家会话并在每个 tick 执行所有玩家的蓝图 + * @en Responsible for managing player sessions and executing all player blueprints each tick + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * const scheduler = new TickScheduler({ + * defaultCpuConfig: { maxCpuTime: 50 }, + * defaultIntentKeyExtractor: (i) => `${i.type}:${i.unitId}` + * }); + * + * // 添加玩家 + * scheduler.addPlayer('player1', blueprint1); + * scheduler.addPlayer('player2', blueprint2); + * + * // 执行 tick + * const result = scheduler.executeTick(gameState); + * ``` + */ +export class TickScheduler< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + private readonly _sessions: Map> = new Map(); + private readonly _config: TickSchedulerConfig; + + constructor(config: TickSchedulerConfig = {}) { + this._config = config; + } + + // ========================================================================= + // 属性 | Properties + // ========================================================================= + + /** + * @zh 获取玩家数量 + * @en Get player count + */ + get playerCount(): number { + return this._sessions.size; + } + + /** + * @zh 获取所有玩家 ID + * @en Get all player IDs + */ + get playerIds(): string[] { + return Array.from(this._sessions.keys()); + } + + // ========================================================================= + // 玩家管理 | Player Management + // ========================================================================= + + /** + * @zh 添加玩家 + * @en Add player + * + * @param playerId - @zh 玩家 ID @en Player ID + * @param blueprint - @zh 蓝图资产 @en Blueprint asset + * @param config - @zh 会话配置(可选,覆盖默认配置)@en Session config (optional, overrides defaults) + * @returns @zh 创建的会话 @en Created session + */ + addPlayer( + playerId: string, + blueprint: BlueprintAsset, + config?: PlayerSessionConfig + ): PlayerSession { + if (this._sessions.has(playerId)) { + throw new Error(`Player ${playerId} already exists`); + } + + const sessionConfig: PlayerSessionConfig = { + cpuConfig: config?.cpuConfig ?? this._config.defaultCpuConfig, + intentKeyExtractor: config?.intentKeyExtractor ?? this._config.defaultIntentKeyExtractor, + debug: config?.debug ?? this._config.debug + }; + + const session = new PlayerSession(playerId, blueprint, sessionConfig); + this._sessions.set(playerId, session); + return session; + } + + /** + * @zh 移除玩家 + * @en Remove player + * + * @param playerId - @zh 玩家 ID @en Player ID + * @returns @zh 是否成功移除 @en Whether removed successfully + */ + removePlayer(playerId: string): boolean { + return this._sessions.delete(playerId); + } + + /** + * @zh 获取玩家会话 + * @en Get player session + * + * @param playerId - @zh 玩家 ID @en Player ID + * @returns @zh 玩家会话(如果存在)@en Player session (if exists) + */ + getSession(playerId: string): PlayerSession | undefined { + return this._sessions.get(playerId); + } + + /** + * @zh 检查玩家是否存在 + * @en Check if player exists + */ + hasPlayer(playerId: string): boolean { + return this._sessions.has(playerId); + } + + /** + * @zh 更新玩家蓝图 + * @en Update player blueprint + * + * @param playerId - @zh 玩家 ID @en Player ID + * @param blueprint - @zh 新蓝图资产 @en New blueprint asset + */ + updatePlayerBlueprint( + playerId: string, + blueprint: BlueprintAsset, + config?: PlayerSessionConfig + ): void { + const existingSession = this._sessions.get(playerId); + const memory = existingSession?.memory ?? {}; + + this._sessions.delete(playerId); + const newSession = this.addPlayer(playerId, blueprint, config); + newSession.setMemory(memory as Record); + } + + // ========================================================================= + // Memory 管理 | Memory Management + // ========================================================================= + + /** + * @zh 设置玩家 Memory + * @en Set player Memory + */ + setPlayerMemory(playerId: string, memory: Record): void { + const session = this._sessions.get(playerId); + if (session) { + session.setMemory(memory); + } + } + + /** + * @zh 获取玩家 Memory + * @en Get player Memory + */ + getPlayerMemory(playerId: string): Readonly> | undefined { + return this._sessions.get(playerId)?.memory; + } + + /** + * @zh 获取所有玩家的 Memory + * @en Get all players' Memory + */ + getAllMemories(): Map>> { + const result = new Map>>(); + for (const [playerId, session] of this._sessions) { + result.set(playerId, session.memory); + } + return result; + } + + // ========================================================================= + // 执行 | Execution + // ========================================================================= + + /** + * @zh 执行一个 tick + * @en Execute one tick + * + * @param gameState - @zh 当前游戏状态 @en Current game state + * @returns @zh Tick 执行结果 @en Tick execution result + */ + executeTick(gameState: TGameState): TickExecutionResult { + const startTime = performance.now(); + const playerResults = new Map>(); + const allIntents: TIntent[] = []; + let successCount = 0; + let failureCount = 0; + + for (const [playerId, session] of this._sessions) { + if (session.state === 'active') { + const result = session.executeTick(gameState); + playerResults.set(playerId, result); + + if (result.success) { + successCount++; + allIntents.push(...result.intents); + } else { + failureCount++; + } + } + } + + const duration = performance.now() - startTime; + + return { + tick: gameState.tick, + duration, + playerResults, + allIntents, + successCount, + failureCount + }; + } + + /** + * @zh 为指定玩家构建游戏状态视图 + * @en Build game state view for specified player + * + * @zh 由游戏项目实现,用于过滤玩家可见的游戏状态 + * @en Implemented by game project, used to filter player-visible game state + */ + buildPlayerView?(gameState: TGameState, playerId: string): TGameState; + + /** + * @zh 使用玩家视图执行 tick + * @en Execute tick with player views + * + * @zh 如果提供了 buildPlayerView,则为每个玩家构建独立视图 + * @en If buildPlayerView is provided, builds independent view for each player + */ + executeTickWithViews( + gameState: TGameState, + buildView: (gameState: TGameState, playerId: string) => TGameState + ): TickExecutionResult { + const startTime = performance.now(); + const playerResults = new Map>(); + const allIntents: TIntent[] = []; + let successCount = 0; + let failureCount = 0; + + for (const [playerId, session] of this._sessions) { + if (session.state === 'active') { + const playerView = buildView(gameState, playerId); + const result = session.executeTick(playerView); + playerResults.set(playerId, result); + + if (result.success) { + successCount++; + allIntents.push(...result.intents); + } else { + failureCount++; + } + } + } + + const duration = performance.now() - startTime; + + return { + tick: gameState.tick, + duration, + playerResults, + allIntents, + successCount, + failureCount + }; + } + + // ========================================================================= + // 状态管理 | State Management + // ========================================================================= + + /** + * @zh 暂停所有玩家 + * @en Suspend all players + */ + suspendAll(): void { + for (const session of this._sessions.values()) { + session.suspend(); + } + } + + /** + * @zh 恢复所有玩家 + * @en Resume all players + */ + resumeAll(): void { + for (const session of this._sessions.values()) { + session.resume(); + } + } + + /** + * @zh 重置所有玩家 + * @en Reset all players + */ + resetAll(): void { + for (const session of this._sessions.values()) { + session.reset(); + } + } + + /** + * @zh 清空所有玩家 + * @en Clear all players + */ + clear(): void { + this._sessions.clear(); + } + + // ========================================================================= + // 统计 | Statistics + // ========================================================================= + + /** + * @zh 获取调度器统计信息 + * @en Get scheduler statistics + */ + getStats(): SchedulerStats { + let totalCpuUsed = 0; + let totalTicksExecuted = 0; + let activeCount = 0; + let suspendedCount = 0; + let errorCount = 0; + + for (const session of this._sessions.values()) { + totalCpuUsed += session.totalCpuUsed; + totalTicksExecuted += session.ticksExecuted; + + switch (session.state) { + case 'active': + activeCount++; + break; + case 'suspended': + suspendedCount++; + break; + case 'error': + errorCount++; + break; + } + } + + return { + playerCount: this._sessions.size, + activeCount, + suspendedCount, + errorCount, + totalCpuUsed, + totalTicksExecuted, + averageCpuPerPlayer: this._sessions.size > 0 ? totalCpuUsed / this._sessions.size : 0 + }; + } +} + +/** + * @zh 调度器统计信息 + * @en Scheduler statistics + */ +export interface SchedulerStats { + readonly playerCount: number; + readonly activeCount: number; + readonly suspendedCount: number; + readonly errorCount: number; + readonly totalCpuUsed: number; + readonly totalTicksExecuted: number; + readonly averageCpuPerPlayer: number; +} diff --git a/packages/script-runtime/src/server/index.ts b/packages/script-runtime/src/server/index.ts new file mode 100644 index 00000000..ee504f16 --- /dev/null +++ b/packages/script-runtime/src/server/index.ts @@ -0,0 +1,30 @@ +/** + * @zh 服务器端模块 + * @en Server-side Module + */ + +// Types +export type { + PlayerTickResult, + TickExecutionResult, + IntentProcessingResult, + GameLoopConfig, + GameLoopState, + GameLoopEvents +} from './types'; + +// PlayerSession +export { PlayerSession } from './PlayerSession'; +export type { PlayerSessionConfig, PlayerSessionState } from './PlayerSession'; + +// TickScheduler +export { TickScheduler } from './TickScheduler'; +export type { TickSchedulerConfig, SchedulerStats } from './TickScheduler'; + +// IntentProcessor +export type { IIntentProcessor, SingleIntentResult } from './IIntentProcessor'; +export { IntentProcessorBase, IntentProcessorRegistry } from './IIntentProcessor'; + +// GameLoop +export { GameLoop, DEFAULT_GAME_LOOP_CONFIG } from './GameLoop'; +export type { GameLoopStats } from './GameLoop'; diff --git a/packages/script-runtime/src/server/types.ts b/packages/script-runtime/src/server/types.ts new file mode 100644 index 00000000..dc2893bb --- /dev/null +++ b/packages/script-runtime/src/server/types.ts @@ -0,0 +1,233 @@ +/** + * @zh 服务器端类型定义 + * @en Server-side type definitions + */ + +import type { IIntent } from '../intent/IntentTypes'; +import type { IGameState, LogEntry } from '../vm/ServerExecutionContext'; +import type { CPUStats } from '../vm/CPULimiter'; + +// ============================================================================= +// 玩家会话类型 | Player Session Types +// ============================================================================= + +/** + * @zh 玩家执行结果 + * @en Player execution result + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface PlayerTickResult { + /** + * @zh 玩家 ID + * @en Player ID + */ + readonly playerId: string; + + /** + * @zh 是否执行成功 + * @en Whether execution succeeded + */ + readonly success: boolean; + + /** + * @zh CPU 使用统计 + * @en CPU usage statistics + */ + readonly cpu: CPUStats; + + /** + * @zh 收集的意图列表 + * @en Collected intents list + */ + readonly intents: readonly TIntent[]; + + /** + * @zh 日志列表 + * @en Log list + */ + readonly logs: readonly LogEntry[]; + + /** + * @zh 错误列表 + * @en Error list + */ + readonly errors: readonly string[]; + + /** + * @zh 更新后的 Memory + * @en Updated Memory + */ + readonly memory: Record; +} + +// ============================================================================= +// Tick 调度类型 | Tick Scheduler Types +// ============================================================================= + +/** + * @zh Tick 执行结果 + * @en Tick execution result + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface TickExecutionResult { + /** + * @zh 当前 tick + * @en Current tick + */ + readonly tick: number; + + /** + * @zh 执行耗时(毫秒) + * @en Execution duration (milliseconds) + */ + readonly duration: number; + + /** + * @zh 所有玩家的执行结果 + * @en All players' execution results + */ + readonly playerResults: ReadonlyMap>; + + /** + * @zh 所有收集的意图(合并后) + * @en All collected intents (merged) + */ + readonly allIntents: readonly TIntent[]; + + /** + * @zh 成功执行的玩家数 + * @en Number of successfully executed players + */ + readonly successCount: number; + + /** + * @zh 执行失败的玩家数 + * @en Number of failed players + */ + readonly failureCount: number; +} + +// ============================================================================= +// 意图处理类型 | Intent Processing Types +// ============================================================================= + +/** + * @zh 意图处理结果 + * @en Intent processing result + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + */ +export interface IntentProcessingResult { + /** + * @zh 更新后的游戏状态 + * @en Updated game state + */ + readonly gameState: TGameState; + + /** + * @zh 处理的意图数量 + * @en Number of processed intents + */ + readonly processedCount: number; + + /** + * @zh 被拒绝的意图数量 + * @en Number of rejected intents + */ + readonly rejectedCount: number; + + /** + * @zh 处理错误 + * @en Processing errors + */ + readonly errors: readonly string[]; +} + +// ============================================================================= +// 游戏循环类型 | Game Loop Types +// ============================================================================= + +/** + * @zh 游戏循环配置 + * @en Game loop configuration + */ +export interface GameLoopConfig { + /** + * @zh Tick 间隔(毫秒) + * @en Tick interval (milliseconds) + * + * @default 1000 + */ + readonly tickInterval?: number; + + /** + * @zh 最大追赶 tick 数(如果落后太多) + * @en Maximum catch-up ticks (if falling behind) + * + * @default 5 + */ + readonly maxCatchUpTicks?: number; + + /** + * @zh 是否在 tick 之间保存 Memory + * @en Whether to save Memory between ticks + * + * @default true + */ + readonly autoSaveMemory?: boolean; + + /** + * @zh Memory 保存间隔(tick 数) + * @en Memory save interval (in ticks) + * + * @default 10 + */ + readonly memorySaveInterval?: number; +} + +/** + * @zh 游戏循环状态 + * @en Game loop state + */ +export type GameLoopState = 'idle' | 'running' | 'paused' | 'stopping'; + +/** + * @zh 游戏循环事件 + * @en Game loop events + */ +export interface GameLoopEvents< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + /** + * @zh Tick 开始前 + * @en Before tick starts + */ + onTickStart?: (tick: number) => void | Promise; + + /** + * @zh 玩家蓝图执行完成后 + * @en After player blueprints executed + */ + onPlayersExecuted?: (result: TickExecutionResult) => void | Promise; + + /** + * @zh 意图处理完成后 + * @en After intents processed + */ + onIntentsProcessed?: (result: IntentProcessingResult) => void | Promise; + + /** + * @zh Tick 结束后 + * @en After tick ends + */ + onTickEnd?: (tick: number, gameState: TGameState) => void | Promise; + + /** + * @zh 发生错误时 + * @en When error occurs + */ + onError?: (error: Error, tick: number) => void | Promise; +} diff --git a/packages/script-runtime/src/tokens.ts b/packages/script-runtime/src/tokens.ts new file mode 100644 index 00000000..5a12e28f --- /dev/null +++ b/packages/script-runtime/src/tokens.ts @@ -0,0 +1,78 @@ +/** + * @zh Script Runtime 模块服务令牌 + * @en Script Runtime module service tokens + * + * @zh 遵循"谁定义接口,谁导出 Token"原则 + * @en Following "who defines interface, who exports Token" principle + */ + +import { createServiceToken } from '@esengine/ecs-framework'; + +import type { ServerBlueprintVM } from './vm/ServerBlueprintVM'; +import type { IMemoryStore } from './persistence/IMemoryStore'; + +// ============================================================================= +// 服务接口 | Service Interfaces +// ============================================================================= + +/** + * @zh 脚本运行时服务接口 + * @en Script runtime service interface + */ +export interface IScriptRuntimeService { + /** + * @zh 为玩家创建 VM + * @en Create VM for player + */ + createPlayerVM(playerId: string, blueprintPath: string): Promise; + + /** + * @zh 获取玩家的 VM + * @en Get player's VM + */ + getPlayerVM(playerId: string): ServerBlueprintVM | undefined; + + /** + * @zh 移除玩家的 VM + * @en Remove player's VM + */ + removePlayerVM(playerId: string): void; + + /** + * @zh 获取所有活跃玩家 ID + * @en Get all active player IDs + */ + getActivePlayerIds(): string[]; +} + +// ============================================================================= +// 服务令牌 | Service Tokens +// ============================================================================= + +/** + * @zh 脚本运行时服务令牌 + * @en Script runtime service token + * + * @example + * ```typescript + * import { ScriptRuntimeServiceToken } from '@esengine/script-runtime'; + * + * const service = context.services.get(ScriptRuntimeServiceToken); + * const vm = await service.createPlayerVM('player1', 'blueprints/main.bp'); + * ``` + */ +export const ScriptRuntimeServiceToken = createServiceToken('scriptRuntimeService'); + +/** + * @zh Memory 存储服务令牌 + * @en Memory store service token + * + * @example + * ```typescript + * import { MemoryStoreToken } from '@esengine/script-runtime'; + * + * const store = context.services.get(MemoryStoreToken); + * const memory = await store.loadPlayerMemory('player1'); + * ``` + */ +export const MemoryStoreToken = createServiceToken('memoryStore'); diff --git a/packages/script-runtime/src/vm/CPULimiter.ts b/packages/script-runtime/src/vm/CPULimiter.ts new file mode 100644 index 00000000..c0b8d639 --- /dev/null +++ b/packages/script-runtime/src/vm/CPULimiter.ts @@ -0,0 +1,318 @@ +/** + * @zh CPU 限制器 + * @en CPU Limiter + * + * @zh 限制蓝图执行的 CPU 时间和执行步数 + * @en Limits CPU time and execution steps for blueprint execution + */ + +/** + * @zh CPU 限制器配置 + * @en CPU limiter configuration + */ +export interface CPULimiterConfig { + /** + * @zh 每 tick 的 CPU 时间限制(毫秒) + * @en CPU time limit per tick (milliseconds) + */ + cpuLimitMs: number; + + /** + * @zh 每 tick 的最大执行步数 + * @en Maximum execution steps per tick + */ + maxSteps: number; + + /** + * @zh CPU 桶最大值 + * @en CPU bucket maximum + */ + bucketMax: number; + + /** + * @zh 每 tick 恢复的 CPU 量 + * @en CPU amount recovered per tick + */ + bucketRecovery: number; +} + +/** + * @zh 默认 CPU 限制配置 + * @en Default CPU limit configuration + */ +export const DEFAULT_CPU_CONFIG: CPULimiterConfig = { + cpuLimitMs: 100, // 100ms per tick + maxSteps: 10000, // 10000 steps per tick + bucketMax: 10000, // 10000ms max bucket + bucketRecovery: 100 // 100ms per tick recovery +}; + +/** + * @zh CPU 使用统计 + * @en CPU usage statistics + */ +export interface CPUStats { + /** + * @zh 已使用的 CPU 时间(毫秒) + * @en Used CPU time (milliseconds) + */ + used: number; + + /** + * @zh CPU 限制(毫秒) + * @en CPU limit (milliseconds) + */ + limit: number; + + /** + * @zh 已执行的步数 + * @en Executed steps + */ + steps: number; + + /** + * @zh 最大步数 + * @en Maximum steps + */ + maxSteps: number; + + /** + * @zh 当前桶值 + * @en Current bucket value + */ + bucket: number; + + /** + * @zh 是否超出限制 + * @en Whether exceeded limit + */ + exceeded: boolean; +} + +/** + * @zh CPU 限制器 + * @en CPU Limiter + * + * @zh 用于限制玩家蓝图的执行资源,类似 Screeps 的 CPU 系统 + * @en Used to limit player blueprint execution resources, similar to Screeps CPU system + * + * @example + * ```typescript + * const limiter = new CPULimiter('player1', config); + * + * // 开始执行 | Start execution + * limiter.start(); + * + * // 在执行节点时检查 | Check during node execution + * if (limiter.checkStep()) { + * // 继续执行 | Continue execution + * } else { + * // 超出限制,停止执行 | Exceeded limit, stop execution + * } + * + * // 结束执行 | End execution + * limiter.end(); + * console.log(`Used: ${limiter.getUsed()}ms`); + * ``` + */ +export class CPULimiter { + /** + * @zh 玩家 ID + * @en Player ID + */ + private readonly _playerId: string; + + /** + * @zh 配置 + * @en Configuration + */ + private readonly _config: CPULimiterConfig; + + /** + * @zh 开始时间 + * @en Start time + */ + private _startTime: number = 0; + + /** + * @zh 已使用的 CPU 时间 + * @en Used CPU time + */ + private _usedCpu: number = 0; + + /** + * @zh 已执行的步数 + * @en Executed steps + */ + private _steps: number = 0; + + /** + * @zh CPU 桶(累积的 CPU 配额) + * @en CPU bucket (accumulated CPU quota) + */ + private _bucket: number; + + /** + * @zh 是否正在执行 + * @en Whether currently executing + */ + private _isRunning: boolean = false; + + /** + * @zh 是否超出限制 + * @en Whether exceeded limit + */ + private _exceeded: boolean = false; + + constructor(playerId: string, config: Partial = {}) { + this._playerId = playerId; + this._config = { ...DEFAULT_CPU_CONFIG, ...config }; + this._bucket = this._config.bucketMax; + } + + /** + * @zh 获取玩家 ID + * @en Get player ID + */ + get playerId(): string { + return this._playerId; + } + + /** + * @zh 获取当前 CPU 桶值 + * @en Get current CPU bucket value + */ + get bucket(): number { + return this._bucket; + } + + /** + * @zh 获取 CPU 限制 + * @en Get CPU limit + */ + get limit(): number { + return this._config.cpuLimitMs; + } + + /** + * @zh 是否超出限制 + * @en Whether exceeded limit + */ + get exceeded(): boolean { + return this._exceeded; + } + + /** + * @zh 开始计时 + * @en Start timing + */ + start(): void { + this._startTime = performance.now(); + this._usedCpu = 0; + this._steps = 0; + this._exceeded = false; + this._isRunning = true; + } + + /** + * @zh 结束计时并更新桶 + * @en End timing and update bucket + */ + end(): void { + if (!this._isRunning) return; + + this._usedCpu = performance.now() - this._startTime; + this._isRunning = false; + + // 从桶中扣除使用的 CPU | Deduct used CPU from bucket + this._bucket -= this._usedCpu; + if (this._bucket < 0) { + this._bucket = 0; + } + } + + /** + * @zh 获取已使用的 CPU 时间 + * @en Get used CPU time + */ + getUsed(): number { + if (this._isRunning) { + return performance.now() - this._startTime; + } + return this._usedCpu; + } + + /** + * @zh 获取已执行的步数 + * @en Get executed steps + */ + getSteps(): number { + return this._steps; + } + + /** + * @zh 检查是否可以继续执行一步 + * @en Check if can continue executing one step + * + * @returns @zh true 如果可以继续,false 如果超出限制 @en true if can continue, false if exceeded + */ + checkStep(): boolean { + this._steps++; + + // 检查步数限制 | Check step limit + if (this._steps > this._config.maxSteps) { + this._exceeded = true; + return false; + } + + // 检查 CPU 时间限制 | Check CPU time limit + const currentTime = performance.now() - this._startTime; + const effectiveLimit = Math.min(this._config.cpuLimitMs, this._bucket); + + if (currentTime > effectiveLimit) { + this._exceeded = true; + return false; + } + + return true; + } + + /** + * @zh 每 tick 恢复桶 + * @en Recover bucket per tick + */ + recoverBucket(): void { + this._bucket += this._config.bucketRecovery; + if (this._bucket > this._config.bucketMax) { + this._bucket = this._config.bucketMax; + } + } + + /** + * @zh 获取 CPU 统计信息 + * @en Get CPU statistics + */ + getStats(): CPUStats { + return { + used: this.getUsed(), + limit: this._config.cpuLimitMs, + steps: this._steps, + maxSteps: this._config.maxSteps, + bucket: this._bucket, + exceeded: this._exceeded + }; + } + + /** + * @zh 重置限制器(用于测试) + * @en Reset limiter (for testing) + */ + reset(): void { + this._startTime = 0; + this._usedCpu = 0; + this._steps = 0; + this._exceeded = false; + this._isRunning = false; + this._bucket = this._config.bucketMax; + } +} diff --git a/packages/script-runtime/src/vm/ServerBlueprintVM.ts b/packages/script-runtime/src/vm/ServerBlueprintVM.ts new file mode 100644 index 00000000..b0b6826f --- /dev/null +++ b/packages/script-runtime/src/vm/ServerBlueprintVM.ts @@ -0,0 +1,618 @@ +/** + * @zh 服务器端蓝图虚拟机 + * @en Server-side Blueprint Virtual Machine + * + * @zh 在服务器端执行玩家蓝图,支持 CPU 限制和意图收集 + * @en Executes player blueprints on server with CPU limiting and intent collection + */ + +import type { + BlueprintAsset, + BlueprintNode, + BlueprintConnection +} from '@esengine/blueprint'; +import { NodeRegistry } from '@esengine/blueprint'; + +import { ServerExecutionContext, type IGameState, type LogEntry } from './ServerExecutionContext'; +import { CPULimiter, type CPULimiterConfig, type CPUStats } from './CPULimiter'; +import { IntentCollector } from '../intent/IntentCollector'; +import type { IIntent, IntentKeyExtractor } from '../intent/IntentTypes'; + +/** + * @zh 服务器 VM 配置 + * @en Server VM configuration + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface ServerVMConfig { + /** + * @zh CPU 限制配置 + * @en CPU limit configuration + */ + cpuConfig?: Partial; + + /** + * @zh 调试模式 + * @en Debug mode + */ + debug?: boolean; + + /** + * @zh 意图键提取器 + * @en Intent key extractor + */ + intentKeyExtractor?: IntentKeyExtractor; +} + +/** + * @zh Tick 执行结果 + * @en Tick execution result + * + * @typeParam TIntent - @zh 意图类型 @en Intent type + */ +export interface TickResult { + /** + * @zh 是否执行成功 + * @en Whether execution succeeded + */ + success: boolean; + + /** + * @zh CPU 使用统计 + * @en CPU usage statistics + */ + cpu: CPUStats; + + /** + * @zh 收集的意图列表 + * @en Collected intents list + */ + intents: TIntent[]; + + /** + * @zh 日志列表 + * @en Log list + */ + logs: LogEntry[]; + + /** + * @zh 错误列表 + * @en Error list + */ + errors: string[]; + + /** + * @zh 更新后的 Memory + * @en Updated Memory + */ + memory: Record; +} + +/** + * @zh 待处理的延迟执行 + * @en Pending delayed execution + */ +interface PendingExecution { + nodeId: string; + execPin: string; + resumeTick: number; +} + +/** + * @zh 节点执行结果 + * @en Node execution result + */ +interface ExecutionResult { + nextExec?: string | null; + outputs?: Record; + yield?: boolean; + delay?: number; + error?: string; +} + +/** + * @zh 服务器端蓝图虚拟机 + * @en Server-side Blueprint Virtual Machine + * + * @zh 专为服务器端设计的蓝图执行引擎,支持: + * @en Blueprint execution engine designed for server-side, supporting: + * + * @zh - CPU 时间和步数限制 + * @en - CPU time and step limiting + * + * @zh - 意图收集(不直接执行操作) + * @en - Intent collection (no direct execution) + * + * @zh - Memory 持久化 + * @en - Memory persistence + * + * @typeParam TGameState - @zh 游戏状态类型 @en Game state type + * @typeParam TIntent - @zh 意图类型 @en Intent type + * + * @example + * ```typescript + * // 游戏项目中定义类型 | Define types in game project + * interface MyGameState extends IGameState { + * units: Map; + * } + * + * interface MyIntent extends IIntent { + * readonly type: 'unit.move' | 'unit.attack'; + * unitId: string; + * } + * + * // 创建 VM | Create VM + * const vm = new ServerBlueprintVM('player1', blueprint, { + * intentKeyExtractor: (intent) => `${intent.type}:${intent.unitId}` + * }); + * + * // 每个 tick 执行 | Execute each tick + * const result = vm.executeTick(gameState, playerMemory); + * ``` + */ +export class ServerBlueprintVM< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + /** + * @zh 玩家 ID + * @en Player ID + */ + private readonly _playerId: string; + + /** + * @zh 蓝图资产 + * @en Blueprint asset + */ + private readonly _blueprint: BlueprintAsset; + + /** + * @zh 执行上下文 + * @en Execution context + */ + private readonly _context: ServerExecutionContext; + + /** + * @zh CPU 限制器 + * @en CPU limiter + */ + private readonly _cpuLimiter: CPULimiter; + + /** + * @zh 意图收集器 + * @en Intent collector + */ + private readonly _intentCollector: IntentCollector; + + /** + * @zh 事件节点缓存 + * @en Event nodes cache + */ + private readonly _eventNodes: Map = new Map(); + + /** + * @zh 连接查找表(按源) + * @en Connection lookup (by source) + */ + private readonly _connectionsBySource: Map = new Map(); + + /** + * @zh 连接查找表(按目标) + * @en Connection lookup (by target) + */ + private readonly _connectionsByTarget: Map = new Map(); + + /** + * @zh 待处理的延迟执行 + * @en Pending delayed executions + */ + private _pendingExecutions: PendingExecution[] = []; + + /** + * @zh 当前 tick + * @en Current tick + */ + private _currentTick: number = 0; + + /** + * @zh 调试模式 + * @en Debug mode + */ + private readonly _debug: boolean; + + /** + * @zh 错误列表 + * @en Error list + */ + private _errors: string[] = []; + + constructor( + playerId: string, + blueprint: BlueprintAsset, + config: ServerVMConfig = {} + ) { + this._playerId = playerId; + this._blueprint = blueprint; + this._debug = config.debug ?? false; + + this._context = new ServerExecutionContext(blueprint, playerId); + + this._cpuLimiter = new CPULimiter(playerId, config.cpuConfig); + + this._intentCollector = new IntentCollector(playerId, { + keyExtractor: config.intentKeyExtractor + }); + + this._context.setCPULimiter(this._cpuLimiter); + this._context.setIntentCollector(this._intentCollector); + + this._buildLookupTables(); + this._cacheEventNodes(); + } + + /** + * @zh 获取玩家 ID + * @en Get player ID + */ + get playerId(): string { + return this._playerId; + } + + /** + * @zh 获取执行上下文 + * @en Get execution context + */ + get context(): ServerExecutionContext { + return this._context; + } + + /** + * @zh 获取 CPU 限制器 + * @en Get CPU limiter + */ + get cpuLimiter(): CPULimiter { + return this._cpuLimiter; + } + + /** + * @zh 获取意图收集器 + * @en Get intent collector + */ + get intentCollector(): IntentCollector { + return this._intentCollector; + } + + /** + * @zh 构建连接查找表 + * @en Build connection lookup tables + */ + private _buildLookupTables(): void { + for (const conn of this._blueprint.connections) { + const sourceKey = `${conn.fromNodeId}.${conn.fromPin}`; + if (!this._connectionsBySource.has(sourceKey)) { + this._connectionsBySource.set(sourceKey, []); + } + this._connectionsBySource.get(sourceKey)!.push(conn); + + const targetKey = `${conn.toNodeId}.${conn.toPin}`; + if (!this._connectionsByTarget.has(targetKey)) { + this._connectionsByTarget.set(targetKey, []); + } + this._connectionsByTarget.get(targetKey)!.push(conn); + } + } + + /** + * @zh 缓存事件节点 + * @en Cache event nodes + */ + private _cacheEventNodes(): void { + for (const node of this._blueprint.nodes) { + if (node.type.startsWith('Event')) { + const eventType = node.type; + if (!this._eventNodes.has(eventType)) { + this._eventNodes.set(eventType, []); + } + this._eventNodes.get(eventType)!.push(node); + } + } + } + + /** + * @zh 执行一个游戏 tick + * @en Execute one game tick + * + * @param gameState - @zh 当前游戏状态 @en Current game state + * @param memory - @zh 玩家 Memory @en Player Memory + * @returns @zh Tick 执行结果 @en Tick execution result + */ + executeTick(gameState: TGameState, memory: Record = {}): TickResult { + this._currentTick = gameState.tick; + this._errors = []; + + this._intentCollector.clear(); + this._intentCollector.setTick(this._currentTick); + this._context.clearLogs(); + this._context.clearOutputCache(); + + this._context.setGameState(gameState); + this._context.setMemory(memory); + + this._cpuLimiter.start(); + + try { + this._processPendingExecutions(); + this._triggerEvent('EventTick'); + } catch (error) { + this._errors.push(`Execution error: ${error}`); + } + + this._cpuLimiter.end(); + this._cpuLimiter.recoverBucket(); + + return { + success: this._errors.length === 0, + cpu: this._cpuLimiter.getStats(), + intents: this._intentCollector.getIntents(), + logs: this._context.getLogs(), + errors: this._errors, + memory: this._context.getMemory() + }; + } + + /** + * @zh 触发事件 + * @en Trigger event + */ + private _triggerEvent(eventType: string, data?: Record): void { + const eventNodes = this._eventNodes.get(eventType); + if (!eventNodes) return; + + for (const node of eventNodes) { + if (!this._context.checkCPU()) { + this._errors.push('CPU limit exceeded'); + return; + } + this._executeFromNode(node, 'exec', data); + } + } + + /** + * @zh 从节点开始执行 + * @en Execute from node + */ + private _executeFromNode( + startNode: BlueprintNode, + startPin: string, + eventData?: Record + ): void { + if (eventData) { + this._context.setOutputs(startNode.id, eventData); + } + + let currentNodeId: string | null = startNode.id; + let currentPin: string = startPin; + + while (currentNodeId) { + if (!this._context.checkCPU()) { + this._errors.push('CPU limit exceeded during execution'); + return; + } + + const connections = this._getConnectionsFromPin(currentNodeId, currentPin); + + if (connections.length === 0) { + break; + } + + const nextConn = connections[0]; + const result = this._executeNode(nextConn.toNodeId); + + if (result.error) { + this._errors.push(`Node ${nextConn.toNodeId}: ${result.error}`); + break; + } + + if (result.delay && result.delay > 0) { + this._pendingExecutions.push({ + nodeId: nextConn.toNodeId, + execPin: result.nextExec ?? 'exec', + resumeTick: this._currentTick + Math.ceil(result.delay) + }); + break; + } + + if (result.yield) { + break; + } + + if (result.nextExec === null) { + break; + } + + currentNodeId = nextConn.toNodeId; + currentPin = result.nextExec ?? 'exec'; + } + } + + /** + * @zh 执行单个节点 + * @en Execute single node + */ + private _executeNode(nodeId: string): ExecutionResult { + const node = this._getNode(nodeId); + if (!node) { + return { error: `Node not found: ${nodeId}` }; + } + + const executor = NodeRegistry.instance.getExecutor(node.type); + if (!executor) { + return { error: `No executor for node type: ${node.type}` }; + } + + try { + if (this._debug) { + console.log(`[ServerVM] Executing: ${node.type} (${nodeId})`); + } + + const compatContext = this._createCompatibleContext() as unknown as Parameters[1]; + + const result = executor.execute(node, compatContext); + + if (result.outputs) { + this._context.setOutputs(nodeId, result.outputs); + } + + return result; + } catch (error) { + return { error: `Execution error: ${error}` }; + } + } + + /** + * @zh 创建与 Blueprint 兼容的执行上下文 + * @en Create Blueprint-compatible execution context + */ + private _createCompatibleContext(): { + blueprint: BlueprintAsset; + deltaTime: number; + time: number; + getNode: (id: string) => BlueprintNode | undefined; + getConnectionsToPin: (nodeId: string, pinName: string) => BlueprintConnection[]; + getConnectionsFromPin: (nodeId: string, pinName: string) => BlueprintConnection[]; + evaluateInput: (nodeId: string, pinName: string, defaultValue?: unknown) => unknown; + setOutputs: (nodeId: string, outputs: Record) => void; + getOutputs: (nodeId: string) => Record | undefined; + getVariable: (name: string) => unknown; + setVariable: (name: string, value: unknown) => void; + intentCollector: IntentCollector; + gameState: TGameState | null; + playerId: string; + memory: Record; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + } { + return { + blueprint: this._blueprint, + deltaTime: this._context.deltaTime, + time: this._context.time, + getNode: (id: string) => this._getNode(id), + getConnectionsToPin: (nodeId: string, pinName: string) => + this._getConnectionsToPin(nodeId, pinName), + getConnectionsFromPin: (nodeId: string, pinName: string) => + this._getConnectionsFromPin(nodeId, pinName), + evaluateInput: (nodeId: string, pinName: string, defaultValue?: unknown) => + this._evaluateInput(nodeId, pinName, defaultValue), + setOutputs: (nodeId: string, outputs: Record) => + this._context.setOutputs(nodeId, outputs), + getOutputs: (nodeId: string) => this._context.getOutputs(nodeId), + getVariable: (name: string) => this._context.getVariable(name), + setVariable: (name: string, value: unknown) => this._context.setVariable(name, value), + intentCollector: this._intentCollector, + gameState: this._context.getGameState(), + playerId: this._playerId, + memory: this._context.getMemory(), + log: (message: string) => this._context.log(message), + warn: (message: string) => this._context.warn(message), + error: (message: string) => this._context.error(message) + }; + } + + /** + * @zh 处理待处理的延迟执行 + * @en Process pending delayed executions + */ + private _processPendingExecutions(): void { + const stillPending: PendingExecution[] = []; + + for (const pending of this._pendingExecutions) { + if (this._currentTick >= pending.resumeTick) { + const node = this._getNode(pending.nodeId); + if (node) { + this._executeFromNode(node, pending.execPin); + } + } else { + stillPending.push(pending); + } + } + + this._pendingExecutions = stillPending; + } + + /** + * @zh 获取节点 + * @en Get node + */ + private _getNode(nodeId: string): BlueprintNode | undefined { + return this._blueprint.nodes.find(n => n.id === nodeId); + } + + /** + * @zh 获取从源引脚的连接 + * @en Get connections from source pin + */ + private _getConnectionsFromPin(nodeId: string, pinName: string): BlueprintConnection[] { + return this._connectionsBySource.get(`${nodeId}.${pinName}`) ?? []; + } + + /** + * @zh 获取到目标引脚的连接 + * @en Get connections to target pin + */ + private _getConnectionsToPin(nodeId: string, pinName: string): BlueprintConnection[] { + return this._connectionsByTarget.get(`${nodeId}.${pinName}`) ?? []; + } + + /** + * @zh 计算输入引脚值 + * @en Evaluate input pin value + */ + private _evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown { + const connections = this._getConnectionsToPin(nodeId, pinName); + + if (connections.length === 0) { + const node = this._getNode(nodeId); + return node?.data[pinName] ?? defaultValue; + } + + const conn = connections[0]; + const cachedOutputs = this._context.getOutputs(conn.fromNodeId); + + if (cachedOutputs && conn.fromPin in cachedOutputs) { + return cachedOutputs[conn.fromPin]; + } + + return defaultValue; + } + + /** + * @zh 获取实例变量 + * @en Get instance variables + */ + getInstanceVariables(): Map { + return this._context.getInstanceVariables(); + } + + /** + * @zh 设置实例变量 + * @en Set instance variables + */ + setInstanceVariables(variables: Map): void { + this._context.setInstanceVariables(variables); + } + + /** + * @zh 重置 VM + * @en Reset VM + */ + reset(): void { + this._pendingExecutions = []; + this._currentTick = 0; + this._errors = []; + this._cpuLimiter.reset(); + this._intentCollector.clear(); + this._context.clearOutputCache(); + this._context.clearLogs(); + } +} diff --git a/packages/script-runtime/src/vm/ServerExecutionContext.ts b/packages/script-runtime/src/vm/ServerExecutionContext.ts new file mode 100644 index 00000000..f58dcda7 --- /dev/null +++ b/packages/script-runtime/src/vm/ServerExecutionContext.ts @@ -0,0 +1,459 @@ +/** + * @zh 服务器端执行上下文 + * @en Server-side Execution Context + * + * @zh 扩展 Blueprint 的 ExecutionContext,添加服务器端特性 + * @en Extends Blueprint's ExecutionContext with server-side features + */ + +import type { BlueprintAsset } from '@esengine/blueprint'; +import type { IIntent } from '../intent/IntentTypes'; +import type { IIntentCollector } from '../intent/IntentCollector'; +import type { CPULimiter } from './CPULimiter'; + +// ============================================================================= +// 基础游戏状态接口 | Base Game State Interface +// ============================================================================= + +/** + * @zh 基础游戏状态接口(引擎级别) + * @en Base game state interface (engine level) + * + * @zh 引擎只定义最基础的时间信息,具体的游戏状态由游戏项目扩展 + * @en Engine only defines basic timing info, specific game state is extended by game projects + * + * @example + * ```typescript + * // 游戏项目中扩展游戏状态 | Extend game state in game project + * interface MyGameState extends IGameState { + * units: Map; + * buildings: Map; + * } + * ``` + */ +export interface IGameState { + /** + * @zh 当前 tick + * @en Current tick + */ + tick: number; + + /** + * @zh 自上次 tick 的时间间隔 + * @en Time interval since last tick + */ + deltaTime: number; +} + +// ============================================================================= +// 日志接口 | Log Interface +// ============================================================================= + +/** + * @zh 日志条目 + * @en Log entry + */ +export interface LogEntry { + level: 'log' | 'warn' | 'error'; + message: string; + timestamp: number; + tick: number; +} + +// ============================================================================= +// 服务器执行上下文 | Server Execution Context +// ============================================================================= + +/** + * @zh 服务器端执行上下文 + * @en Server-side Execution Context + * + * @zh 提供蓝图执行时访问游戏状态和收集意图的能力 + * @en Provides ability to access game state and collect intents during blueprint execution + * + * @typeParam TGameState - @zh 游戏状态类型,由游戏项目定义 @en Game state type, defined by game project + * @typeParam TIntent - @zh 意图类型,由游戏项目定义 @en Intent type, defined by game project + * + * @example + * ```typescript + * // 游戏项目中定义具体类型 | Define specific types in game project + * interface MyGameState extends IGameState { + * units: Map; + * resources: Map; + * } + * + * interface MyIntent extends IIntent { + * readonly type: 'unit.move' | 'unit.attack'; + * unitId: string; + * } + * + * // 创建上下文 | Create context + * const context = new ServerExecutionContext(blueprint, 'player1'); + * ``` + */ +export class ServerExecutionContext< + TGameState extends IGameState = IGameState, + TIntent extends IIntent = IIntent +> { + /** + * @zh 蓝图资产 + * @en Blueprint asset + */ + readonly blueprint: BlueprintAsset; + + /** + * @zh 玩家 ID + * @en Player ID + */ + readonly playerId: string; + + /** + * @zh 当前游戏状态 + * @en Current game state + */ + private _gameState: TGameState | null = null; + + /** + * @zh 意图收集器 + * @en Intent collector + */ + private _intentCollector: IIntentCollector | null = null; + + /** + * @zh CPU 限制器 + * @en CPU limiter + */ + private _cpuLimiter: CPULimiter | null = null; + + /** + * @zh 玩家持久化数据(Memory) + * @en Player persistent data (Memory) + */ + private _memory: Record = {}; + + /** + * @zh 日志列表 + * @en Log list + */ + private _logs: LogEntry[] = []; + + /** + * @zh 帧增量时间 + * @en Frame delta time + */ + deltaTime: number = 0; + + /** + * @zh 自开始以来的总时间 + * @en Total time since start + */ + time: number = 0; + + /** + * @zh 节点输出缓存 + * @en Node output cache + */ + private _outputCache: Map> = new Map(); + + /** + * @zh 实例变量 + * @en Instance variables + */ + private _instanceVariables: Map = new Map(); + + /** + * @zh 局部变量 + * @en Local variables + */ + private _localVariables: Map = new Map(); + + /** + * @zh 全局变量(所有玩家共享) + * @en Global variables (shared by all players) + */ + private static _globalVariables: Map = new Map(); + + constructor(blueprint: BlueprintAsset, playerId: string) { + this.blueprint = blueprint; + this.playerId = playerId; + + for (const variable of blueprint.variables) { + if (variable.scope === 'instance') { + this._instanceVariables.set(variable.name, variable.defaultValue); + } + } + } + + // ========================================================================= + // 游戏状态访问 | Game State Access + // ========================================================================= + + /** + * @zh 设置当前游戏状态 + * @en Set current game state + */ + setGameState(state: TGameState): void { + this._gameState = state; + this.deltaTime = state.deltaTime; + } + + /** + * @zh 获取当前游戏状态 + * @en Get current game state + */ + getGameState(): TGameState | null { + return this._gameState; + } + + /** + * @zh 获取当前 tick + * @en Get current tick + */ + getTick(): number { + return this._gameState?.tick ?? 0; + } + + // ========================================================================= + // 意图收集 | Intent Collection + // ========================================================================= + + /** + * @zh 设置意图收集器 + * @en Set intent collector + */ + setIntentCollector(collector: IIntentCollector): void { + this._intentCollector = collector; + } + + /** + * @zh 获取意图收集器 + * @en Get intent collector + */ + get intentCollector(): IIntentCollector | null { + return this._intentCollector; + } + + // ========================================================================= + // CPU 限制 | CPU Limiting + // ========================================================================= + + /** + * @zh 设置 CPU 限制器 + * @en Set CPU limiter + */ + setCPULimiter(limiter: CPULimiter): void { + this._cpuLimiter = limiter; + } + + /** + * @zh 获取 CPU 限制器 + * @en Get CPU limiter + */ + get cpuLimiter(): CPULimiter | null { + return this._cpuLimiter; + } + + /** + * @zh 检查是否可以继续执行 + * @en Check if can continue execution + */ + checkCPU(): boolean { + return this._cpuLimiter?.checkStep() ?? true; + } + + // ========================================================================= + // Memory 访问 | Memory Access + // ========================================================================= + + /** + * @zh 设置玩家 Memory + * @en Set player Memory + */ + setMemory(memory: Record): void { + this._memory = memory; + } + + /** + * @zh 获取玩家 Memory + * @en Get player Memory + */ + getMemory(): Record { + return this._memory; + } + + /** + * @zh 获取 Memory 中的值 + * @en Get value from Memory + */ + getMemoryValue(key: string): T | undefined { + return this._memory[key] as T | undefined; + } + + /** + * @zh 设置 Memory 中的值 + * @en Set value in Memory + */ + setMemoryValue(key: string, value: unknown): void { + this._memory[key] = value; + } + + // ========================================================================= + // 日志 | Logging + // ========================================================================= + + /** + * @zh 添加日志 + * @en Add log + */ + log(message: string): void { + this._logs.push({ + level: 'log', + message, + timestamp: Date.now(), + tick: this.getTick() + }); + } + + /** + * @zh 添加警告 + * @en Add warning + */ + warn(message: string): void { + this._logs.push({ + level: 'warn', + message, + timestamp: Date.now(), + tick: this.getTick() + }); + } + + /** + * @zh 添加错误 + * @en Add error + */ + error(message: string): void { + this._logs.push({ + level: 'error', + message, + timestamp: Date.now(), + tick: this.getTick() + }); + } + + /** + * @zh 获取日志列表 + * @en Get log list + */ + getLogs(): LogEntry[] { + return [...this._logs]; + } + + /** + * @zh 清除日志 + * @en Clear logs + */ + clearLogs(): void { + this._logs = []; + } + + // ========================================================================= + // 变量管理 | Variable Management + // ========================================================================= + + /** + * @zh 获取变量值 + * @en Get variable value + */ + getVariable(name: string): unknown { + if (this._localVariables.has(name)) { + return this._localVariables.get(name); + } + if (this._instanceVariables.has(name)) { + return this._instanceVariables.get(name); + } + if (ServerExecutionContext._globalVariables.has(name)) { + return ServerExecutionContext._globalVariables.get(name); + } + + const varDef = this.blueprint.variables.find(v => v.name === name); + return varDef?.defaultValue; + } + + /** + * @zh 设置变量值 + * @en Set variable value + */ + setVariable(name: string, value: unknown): void { + const varDef = this.blueprint.variables.find(v => v.name === name); + + if (!varDef) { + this._localVariables.set(name, value); + return; + } + + switch (varDef.scope) { + case 'local': + this._localVariables.set(name, value); + break; + case 'instance': + this._instanceVariables.set(name, value); + break; + case 'global': + ServerExecutionContext._globalVariables.set(name, value); + break; + } + } + + /** + * @zh 获取实例变量 + * @en Get instance variables + */ + getInstanceVariables(): Map { + return new Map(this._instanceVariables); + } + + /** + * @zh 设置实例变量 + * @en Set instance variables + */ + setInstanceVariables(variables: Map): void { + this._instanceVariables = new Map(variables); + } + + // ========================================================================= + // 输出缓存 | Output Cache + // ========================================================================= + + /** + * @zh 设置节点输出 + * @en Set node outputs + */ + setOutputs(nodeId: string, outputs: Record): void { + this._outputCache.set(nodeId, outputs); + } + + /** + * @zh 获取节点输出 + * @en Get node outputs + */ + getOutputs(nodeId: string): Record | undefined { + return this._outputCache.get(nodeId); + } + + /** + * @zh 清除输出缓存 + * @en Clear output cache + */ + clearOutputCache(): void { + this._outputCache.clear(); + this._localVariables.clear(); + } + + /** + * @zh 清除全局变量 + * @en Clear global variables + */ + static clearGlobalVariables(): void { + ServerExecutionContext._globalVariables.clear(); + } +} diff --git a/packages/script-runtime/tsconfig.build.json b/packages/script-runtime/tsconfig.build.json new file mode 100644 index 00000000..bf8abf7b --- /dev/null +++ b/packages/script-runtime/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/script-runtime/tsconfig.json b/packages/script-runtime/tsconfig.json new file mode 100644 index 00000000..f852ad22 --- /dev/null +++ b/packages/script-runtime/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" }, + { "path": "../blueprint" } + ] +} diff --git a/packages/script-runtime/tsup.config.ts b/packages/script-runtime/tsup.config.ts new file mode 100644 index 00000000..21ebc9c4 --- /dev/null +++ b/packages/script-runtime/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + external: [ + '@esengine/ecs-framework', + '@esengine/engine-core', + '@esengine/blueprint' + ], + tsconfig: 'tsconfig.build.json' +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52a70bb9..534cce43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1596,6 +1596,37 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/script-runtime: + dependencies: + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@esengine/blueprint': + specifier: workspace:* + version: link:../blueprint + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@esengine/engine-core': + specifier: workspace:* + version: link:../engine-core + '@types/node': + specifier: ^20.19.17 + version: 20.19.25 + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/sdk: dependencies: '@esengine/asset-system':