-
Notifications
You must be signed in to change notification settings - Fork 107
Open
Description
The fx singleton exposes 116+ static members and relies heavily on global state (globalThis.fx). This creates several architectural issues:
- Testing difficulty: Cannot isolate units due to shared global state
- Multiple instances impossible: Cannot run parallel formula contexts
- Hidden dependencies: Components rely on global state implicitly
- Memory leaks: Global state persists even when not needed
- SSR incompatibility: Server-side rendering requires isolated contexts
Current Architecture
// Global singleton pattern
class System {
static player: Player | null
static sceneFolder: Folder | null
static targetFolder: Folder | null
static cache: Map<string, unknown>
// ... 112 more static members
}
globalThis.fx = SystemProblems:
// Test A modifies global state
fx.setPlayer(playerA)
// Test B sees Test A's state (pollution)
const player = fx.getPlayer() // Returns playerA!
// Cannot create isolated contexts
const context1 = ??? // Not possible
const context2 = ??? // Not possibleProposed Solution: Context-Based Architecture
1. FxContext Class
// src/core/FxContext.ts
export interface FxContextOptions {
id?: string
parent?: FxContext
isolated?: boolean
}
export class FxContext {
readonly id: string
private parent?: FxContext
// Instance state (not static)
private player: Player | null = null
private sceneFolder: Folder | null = null
private targetFolder: Folder | null = null
private cache: Map<string, unknown> = new Map()
private eventManager: EventManager
constructor(options: FxContextOptions = {}) {
this.id = options.id ?? generateId()
this.parent = options.parent
this.eventManager = new EventManager()
}
// Player management
setPlayer(player: Player): void { this.player = player }
getPlayer(): Player | null { return this.player ?? this.parent?.getPlayer() ?? null }
// Folder management
setSceneFolder(folder: Folder): void { this.sceneFolder = folder }
getSceneFolder(): Folder | null { return this.sceneFolder }
// Formula evaluation
evaluate(expression: string): number {
return this.expressionParser.evaluate(expression, this.getScope())
}
// Create child context (inherits from parent)
createChild(options?: Partial<FxContextOptions>): FxContext {
return new FxContext({ ...options, parent: this })
}
// Cleanup
dispose(): void {
this.cache.clear()
this.eventManager.removeAllListeners()
this.player = null
this.sceneFolder = null
}
}2. Context Factory
// src/core/FxContextFactory.ts
export class FxContextFactory {
private static contexts: Map<string, FxContext> = new Map()
private static defaultContext: FxContext | null = null
static create(options?: FxContextOptions): FxContext {
const context = new FxContext(options)
this.contexts.set(context.id, context)
return context
}
static getDefault(): FxContext {
if (!this.defaultContext) {
this.defaultContext = this.create({ id: 'default' })
}
return this.defaultContext
}
static get(id: string): FxContext | undefined {
return this.contexts.get(id)
}
static dispose(id: string): void {
const context = this.contexts.get(id)
context?.dispose()
this.contexts.delete(id)
}
static disposeAll(): void {
this.contexts.forEach(ctx => ctx.dispose())
this.contexts.clear()
this.defaultContext = null
}
}3. Backward-Compatible Facade
// src/index.ts
// Maintain backward compatibility
export const fx = {
// Delegate to default context
get player() { return FxContextFactory.getDefault().getPlayer() },
setPlayer(p: Player) { FxContextFactory.getDefault().setPlayer(p) },
evaluate(expr: string) {
return FxContextFactory.getDefault().evaluate(expr)
},
// New: Context management
createContext(options?: FxContextOptions): FxContext {
return FxContextFactory.create(options)
},
getContext(id: string): FxContext | undefined {
return FxContextFactory.get(id)
},
// ... other delegated methods
}4. Usage Examples
Isolated Testing
describe('Player Tests', () => {
let context: FxContext
beforeEach(() => {
context = fx.createContext({ isolated: true })
})
afterEach(() => {
context.dispose()
})
it('should calculate damage', () => {
context.setPlayer(new Player({ ATK: 100 }))
const damage = context.evaluate('ATK * 2')
expect(damage).toBe(200)
})
})Multiple Game Instances
// Multiplayer server with isolated contexts per session
class GameSession {
private context: FxContext
constructor(sessionId: string) {
this.context = fx.createContext({ id: sessionId })
}
processAction(action: Action): void {
// Each session has isolated state
this.context.evaluate(action.formula)
}
destroy(): void {
this.context.dispose()
}
}Nested Contexts (Inheritance)
// Global defaults
const globalContext = fx.createContext({ id: 'global' })
globalContext.setVariable('BASE_DAMAGE', 10)
// Combat context inherits global
const combatContext = globalContext.createChild({ id: 'combat' })
combatContext.setVariable('CRIT_MULTIPLIER', 1.5)
// Can access both
combatContext.evaluate('BASE_DAMAGE * CRIT_MULTIPLIER') // Works!Migration Path
Phase 1: Internal Refactoring
- Convert static members to instance members
- Create FxContext class internally
- No public API changes
Phase 2: Expose Context API
- Add
fx.createContext()method - Document new context-based approach
- Maintain backward compatibility
Phase 3: Deprecate Global Access
- Log deprecation warnings for direct global access
- Recommend context-based usage
- Update documentation
Phase 4: Remove Global State (Major Version)
- Remove
globalThis.fxexposure - Require explicit context creation
- Breaking change in v3.0.0
Benefits
| Aspect | Before | After |
|---|---|---|
| Testing | Shared state pollution | Isolated contexts |
| Parallelism | Single instance | Multiple instances |
| Memory | Persistent global | Disposable contexts |
| SSR | Not compatible | Fully compatible |
| Dependencies | Hidden globals | Explicit injection |
Metadata
Metadata
Assignees
Labels
No labels