dockfries / infernus

Node.js library for scripting Open Multiplayer.
https://dockfries.github.io/infernus/
MIT License
21 stars 2 forks source link

Ideas about middleware callback patterns #27

Closed dockfries closed 11 months ago

dockfries commented 1 year ago

It's just an experiment, and I don't have the energy to refactor yet.

enum EventSpecialKey {
  MiddlewareReady = '__isReady__'
}

type PromisifyCallbackRetType = number | boolean | Promise<any>

interface Middleware {
  setup?: (...args: any[]) => void;
  updated: (next: () => number, ...args: any[]) => PromisifyCallbackRetType;
  unmounted?: (...args: any[]) => void;
}

type PlayerEventCallback = 'connect' | 'disconnect';

function promisifyCallback(result: PromisifyCallbackRetType, defaultValue: boolean = true) {
  if (result instanceof Promise) {
    return +defaultValue;
  }
  const ret = +result;
  return isNaN(ret) ? +defaultValue : ret;
}

function executeMiddlewares(event: { middlewares: Record<string, Middleware[]> }, callback: string, defaultValue: boolean, ...args: any[]) {
  const middlewares = event.middlewares[callback];

  for (const m of middlewares) {
    if (m.setup && !Reflect.get(m, EventSpecialKey.MiddlewareReady)) {
      m.setup(...args);
      Reflect.set(m, EventSpecialKey.MiddlewareReady, true);
    }
  }

  let index = 0;

  const next = () => {
    index++;
    if (index < middlewares.length) {
      return promisifyCallback(middlewares[index].updated(next, ...args));
    }
    for (const m of middlewares) {
      if (m.unmounted && Reflect.get(m, EventSpecialKey.MiddlewareReady)) {
        m.unmounted(...args);
        Reflect.set(m, EventSpecialKey.MiddlewareReady, false);
      }
    }
    return +defaultValue;
  };

  if (middlewares.length > 0) {
    return promisifyCallback(middlewares[index].updated(next, ...args))
  }
  return +defaultValue;
}

function OnPlayerConnect() {
  // Math.round(Math.random() * 10)
  return executeMiddlewares(PlayerEvent, 'connect', true, 0);
}

function OnPlayerDisconnect() {
  // Math.round(Math.random() * 10)
  return executeMiddlewares(PlayerEvent, 'disconnect', true, 0);
}

class Player {
  name: string = ''
  constructor(public readonly id: number) {
  }
}

class PlayerEvent<T extends Player> {

  static readonly middlewares: Record<PlayerEventCallback, Middleware[]> = {
    'connect': [],
    'disconnect': []
  };

  static readonly players = new Map<number, { context: PlayerEvent<any>, value: Player }[]>();

  constructor(private playerConstructor: new (id: number) => T) {
  }

  private getProxyPlayer(player: T) {
    return new Proxy(player, {
      set(target, p, newValue) {
        PlayerEvent.players.get(player.id)?.forEach(item => {
          const has = Reflect.has(item.value, p);
          const descriptor = Reflect.getOwnPropertyDescriptor(item.value, p)
          const writeable = descriptor && descriptor.writable;
          if (has && writeable) {
            Reflect.set(item.value, p, newValue)
          }
        })
        return true
      },

    })
  }

  private setupMiddleware(playerId: number) {
    let players = PlayerEvent.players.get(playerId);

    if (!players) {
      players = [{ context: this, value: new this.playerConstructor(playerId) }];
      PlayerEvent.players.set(playerId, players);
    }

    let player = players.find(p => p.context === this);
    if (!player) {
      const addPlayer = { context: this, value: new this.playerConstructor(playerId) };
      players.push(addPlayer);
      player = addPlayer;
    }
  }

  private unmountedMiddleware(playerId: number) {
    let players = PlayerEvent.players.get(playerId);

    if (!players) return;

    let playerIdx = players.findIndex(p => p.context === this);

    if (playerIdx === -1) return;

    players.splice(playerIdx, 1);
  }

  onConnect(fn: (next: () => ReturnType<typeof promisifyCallback>, player: T) => PromisifyCallbackRetType) {
    PlayerEvent.middlewares['connect'].push({
      setup: this.setupMiddleware.bind(this),
      updated: (next, playerId: number) => {
        const players = PlayerEvent.players.get(playerId)!;
        const player = players.find(p => p.context === this)!;

        const proxyPlayer = this.getProxyPlayer(player.value as T);
        return fn(next, proxyPlayer);
      }
    })
  }

  onDisconnect(fn: (next: () => ReturnType<typeof promisifyCallback>, player: T) => PromisifyCallbackRetType) {
    PlayerEvent.middlewares['disconnect'].push({
      setup: this.setupMiddleware.bind(this),
      updated: (next, playerId: number) => {
        const players = PlayerEvent.players.get(playerId);

        if (!players) return next();

        const player = players.find(p => p.context === this);

        if (!player) return next();

        const proxyPlayer = this.getProxyPlayer(player.value as T);
        return fn(next, proxyPlayer);
      },
      unmounted: this.unmountedMiddleware.bind(this)
    })
  }
}

const playerEvent = new PlayerEvent(Player);
const playerEvent2 = new PlayerEvent(Player);

playerEvent.onConnect((next, player) => {
  player.name = 'a';
  return next();
})

playerEvent2.onConnect((next, player) => {
  console.log(player)
  return next();
})

playerEvent.onConnect((next, player) => {
  player.name = 'b';
  return next();
})

playerEvent.onDisconnect((next, player) => {
  console.log(player);
  return next();
})

playerEvent.onDisconnect((next, player) => {
  console.log(player);
  next();
  console.log(PlayerEvent.players)
  return true;
})

playerEvent2.onDisconnect((next, player) => {
  console.log(player);
  return next();
})

console.log(OnPlayerConnect());
setTimeout(() => {
  console.log(OnPlayerDisconnect())
}, 1500)

Here are the usage guidelines:

  1. Use a proxy to handle the assignment issue after middleware execution for multiple instances.
  2. "await next()" is not supported, but the middleware supports async functions.
  3. When encountering async in a middleware, it will return the defaultValue set instead of the result after awaiting. It also means that the return value of subsequent middlewares is not important. If all the middlewares are synchronous functions until the last one, and the last one also returns the result of "next()", it will return the defaultValue.
  4. If you don't have a better idea, you can return the result of "next()" after each middleware callback.
  5. Usually, the callbacks for creating and destroying each event instance should be registered and paired. Otherwise, unpredictable accidents may occur, such as the "onConnect" and "onDisconnect" events for a player, or the "onCreated" and "onDestroyed" events for an object.
dockfries commented 1 year ago

Since the current structure is too complex, I may remove generics for classes and event classes if I have time to refactor in the future.

dockfries commented 11 months ago

Done