ssube/textual-engine

View on GitHub
src/service/state/LocalState.ts

Summary

Maintainability
F
4 days
Test Coverage
A
99%
/* eslint-disable max-lines */
import { doesExist, InvalidArgumentError, isNone, mustDefault, mustExist, mustFind } from '@apextoaster/js-utils';
import { Container, Inject, Logger } from 'noicejs';

import { NotInitializedError } from '../../error/NotInitializedError.js';
import { ScriptTargetError } from '../../error/ScriptTargetError.js';
import { Command } from '../../model/Command.js';
import { Actor, ACTOR_TYPE, ActorSource, isActor, ReadonlyActor } from '../../model/entity/Actor.js';
import { EntityForType, WorldEntity, WorldEntityType } from '../../model/entity/index.js';
import { ITEM_TYPE } from '../../model/entity/Item.js';
import { PORTAL_TYPE } from '../../model/entity/Portal.js';
import { Room, ROOM_TYPE } from '../../model/entity/Room.js';
import { DataFile } from '../../model/file/Data.js';
import { WorldState } from '../../model/world/State.js';
import { WorldTemplate } from '../../model/world/Template.js';
import {
  INJECT_COUNTER,
  INJECT_EVENT,
  INJECT_LOGGER,
  INJECT_RANDOM,
  INJECT_SCRIPT,
  InjectedOptions,
} from '../../module/index.js';
import { ShowVolume, StateSource } from '../../util/actor/index.js';
import { CompletionSet } from '../../util/async/CompletionSet.js';
import { catchAndLog, onceEvent } from '../../util/async/event.js';
import { randomItem } from '../../util/collection/array.js';
import { StackMap } from '../../util/collection/StackMap.js';
import {
  EVENT_ACTOR_COMMAND,
  EVENT_ACTOR_JOIN,
  EVENT_LOADER_DONE,
  EVENT_LOADER_READ,
  EVENT_LOADER_SAVE,
  EVENT_LOADER_STATE,
  EVENT_LOADER_WORLD,
  EVENT_LOCALE_BUNDLE,
  EVENT_STATE_JOIN,
  EVENT_STATE_LOAD,
  EVENT_STATE_OUTPUT,
  EVENT_STATE_QUIT,
  EVENT_STATE_ROOM,
  EVENT_STATE_STEP,
  EVENT_STATE_WORLD,
  META_CREATE,
  META_DEBUG,
  META_GRAPH,
  META_HELP,
  META_LOAD,
  META_QUIT,
  META_SAVE,
  META_VERBS,
  META_WORLDS,
  SIGNAL_STEP,
  STAT_SCORE,
  VERB_PREFIX,
} from '../../util/constants.js';
import { debugState, graphState } from '../../util/entity/debug.js';
import { StateEntityGenerator } from '../../util/entity/EntityGenerator.js';
import {
  EntityTransfer,
  isActorTransfer,
  isItemTransfer,
  isRoomTransfer,
  StateEntityTransfer,
} from '../../util/entity/EntityTransfer.js';
import { findMatching, findRoom, SearchFilter } from '../../util/entity/find.js';
import { zeroStep } from '../../util/entity/index.js';
import { getVerbScripts } from '../../util/script/index.js';
import { makeServiceLogger } from '../../util/service/index.js';
import { findByBaseId } from '../../util/template/index.js';
import { Immutable } from '../../util/types.js';
import { ActorCommandEvent, ActorJoinEvent } from '../actor/events.js';
import { Counter } from '../counter/index.js';
import { EventBus } from '../event/index.js';
import { hasState, LoaderReadEvent, LoaderStateEvent, LoaderWorldEvent } from '../loader/events.js';
import { LocaleContext } from '../locale/index.js';
import { RandomService } from '../random/index.js';
import { ScriptContext, ScriptService, SuppliedScope } from '../script/index.js';
import { StateService, StepResult } from './index.js';

type StateScope = Omit<SuppliedScope, 'source'>;

@Inject(
  INJECT_COUNTER,
  INJECT_EVENT,
  INJECT_LOGGER,
  INJECT_RANDOM,
  INJECT_SCRIPT,
)
export class LocalStateService implements StateService {
  protected container: Container;
  protected counter: Counter;
  protected event: EventBus;
  protected logger: Logger;
  protected random: RandomService;
  protected script: ScriptService;

  protected commandBuffer: StackMap<ReadonlyActor, Command>;
  protected commandQueue: CompletionSet<ReadonlyActor>;
  protected loadedWorlds: Map<string, WorldTemplate>;

  protected generator?: StateEntityGenerator;
  protected state?: WorldState;
  protected transfer?: StateEntityTransfer;
  protected world?: WorldTemplate;

  constructor(options: InjectedOptions) {
    this.container = options.container;
    this.counter = mustExist(options[INJECT_COUNTER]);
    this.event = mustExist(options[INJECT_EVENT]);
    this.logger = makeServiceLogger(options[INJECT_LOGGER], this);
    this.random = mustExist(options[INJECT_RANDOM]);
    this.script = mustExist(options[INJECT_SCRIPT]);

    this.commandBuffer = new StackMap();
    this.commandQueue = new CompletionSet();
    this.loadedWorlds = new Map();
  }

  public async start(): Promise<void> {
    // TODO: should be injected
    this.generator = await this.container.create(StateEntityGenerator);
    this.transfer = await this.container.create(StateEntityTransfer);

    this.event.on(EVENT_LOADER_WORLD, (event: LoaderWorldEvent) => {
      catchAndLog(this.onWorld(event.world), this.logger, 'error during world handler');
    }, this);

    this.event.on(EVENT_ACTOR_COMMAND, (event: ActorCommandEvent) => {
      catchAndLog(this.onCommand(event), this.logger, 'error during line handler');
    }, this);

    this.event.on(EVENT_ACTOR_JOIN, (event) => {
      catchAndLog(this.onJoin(event), this.logger, 'error during join handler');
    }, this);
  }

  public async stop(): Promise<void> {
    this.event.removeGroup(this);
  }

  // #region event handlers
  /**
   * Step the internal world state, simulating some turns and time passing.
   */
  public async onCommand(event: ActorCommandEvent): Promise<void> {
    const { actor, command } = event;

    this.logger.debug({
      actor,
      command,
    }, 'handling command event');

    // handle meta commands
    switch (command.verb) {
      case META_CREATE:
        await this.doCreate(event);
        break;
      case META_DEBUG:
        await this.doDebug();
        break;
      case META_GRAPH:
        await this.doGraph(event);
        break;
      case META_HELP:
        await this.doHelp(event);
        break;
      case META_LOAD:
        await this.doLoad(event);
        break;
      case META_QUIT:
        await this.doQuit();
        break;
      case META_SAVE:
        await this.doSave(event);
        break;
      case META_WORLDS:
        await this.doWorlds();
        break;
      default: {
        await this.doStep(event);
      }
    }
  }

  /**
   * A new player is joining and their actor must be found or created.
   */
  public async onJoin(event: ActorJoinEvent): Promise<void> {
    const generator = mustExist(this.generator);
    const state = mustExist(this.state);
    const world = mustExist(this.world);

    // find an existing actor, if one exists
    const [existingActor] = findMatching(state.rooms, {
      meta: {
        id: event.pid,
      },
      type: ACTOR_TYPE,
    });
    if (isActor(existingActor)) {
      const [room] = findRoom(state, {
        meta: {
          id: existingActor.meta.id,
        },
      });

      this.event.emit(EVENT_STATE_JOIN, {
        actor: existingActor,
        pid: event.pid,
        room,
      });

      await this.stepEnter({
        actor: existingActor,
        room,
      });
    } else {
      // pick a starting actor and create it
      const actorRef = randomItem(world.start.actors, this.random);
      const actorTemplate = findByBaseId(world.templates.actors, actorRef.id);

      this.logger.debug({
        template: actorTemplate,
      }, 'creating player actor from template');

      const actor = await generator.createActor(actorTemplate, ActorSource.PLAYER);
      actor.meta.id = event.pid;

      this.logger.debug({
        actor,
      }, 'created player actor, placing in room');

      const room = mustFind(state.rooms, (it) => it.meta.id === state.start.room);
      room.actors.push(actor);

      this.logger.debug({
        pid: event.pid,
      }, 'emitting player join event');
      this.event.emit(EVENT_STATE_JOIN, {
        actor,
        pid: event.pid,
        room,
      });

      this.logger.debug({ actor, room }, 'player entering room');
      await this.stepEnter({
        actor,
        room,
      });
    }
  }

  /**
   * A new world has been loaded and needs to be registered.
   */
  public async onWorld(world: WorldTemplate): Promise<void> {
    this.logger.debug({ world: world.meta.id }, 'registering loaded world');
    this.loadedWorlds.set(world.meta.id, world);

    this.event.emit(EVENT_STATE_WORLD, {
      worlds: Array.from(this.loadedWorlds.values()).map((it) => it.meta),
    });
  }
  // #endregion event handlers

  // #region meta commands
  /**
   * Create a new world and invite players to join.
   */
  public async doCreate(event: ActorCommandEvent): Promise<void> {
    const generator = mustExist(this.generator);

    const [id, seed] = event.command.targets;
    const depth = event.command.index;
    this.logger.debug({
      depth,
      id,
      seed,
      worlds: this.loadedWorlds.keys(),
    }, 'creating new world state');

    // find the world, prep the generator
    const world = mustExist(this.loadedWorlds.get(id));

    // load the world locale
    this.event.emit(EVENT_LOCALE_BUNDLE, {
      name: 'world',
      bundle: world.locale,
    });

    // create a state
    generator.setWorld(world);
    this.world = world;
    this.state = await generator.createState({
      depth,
      id,
      seed,
    });

    this.event.emit(EVENT_STATE_OUTPUT, {
      context: {
        depth,
        seed,
        state: this.state.meta,
        world: id,
      },
      line: 'meta.create',
      step: zeroStep(),
      volume: ShowVolume.WORLD,
    });

    this.event.emit(EVENT_STATE_LOAD, {
      state: this.state.meta.name,
      world: this.state.meta.template,
    });
  }

  /**
   * Print debug representation of the world state.
   */
  public async doDebug(): Promise<void> {
    if (isNone(this.state)) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        line: 'meta.debug.missing',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
      return;
    }

    const lines = debugState(this.state);

    for (const line of lines) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        line,
        step: this.state.step,
        volume: ShowVolume.WORLD,
      });
    }
  }

  /**
   * Print graphviz representation of the world state.
   */
  public async doGraph(event: ActorCommandEvent): Promise<void> {
    if (isNone(this.state)) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        line: 'meta.graph.missing',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
      return;
    }


    const lines = graphState(this.state);
    const data = lines.join('\n');
    const [path] = event.command.targets;

    const doneEvent = onceEvent<LoaderReadEvent>(this.event, EVENT_LOADER_DONE);

    this.event.emit(EVENT_LOADER_SAVE, {
      data,
      path,
    });

    await doneEvent;

    this.event.emit(EVENT_STATE_OUTPUT, {
      context: {
        path,
        size: this.state.rooms.length,
      },
      line: 'debug.graph.summary',
      step: this.state.step,
      volume: ShowVolume.WORLD,
    });
  }

  /**
   * Print available verbs.
   */
  public async doHelp(event: ActorCommandEvent): Promise<void> {
    const scripts = getVerbScripts(event);
    const worldVerbs = Array.from(scripts.keys()).filter((it) => it.startsWith(VERB_PREFIX));

    this.logger.debug({ event, worldVerbs }, 'collected world verbs for help');
    const helpVerbs = [
      ...worldVerbs,
      ...META_VERBS,
    ].sort()
      .map((it) => `$t(${it})`)
      .join(', ');

    this.event.emit(EVENT_STATE_OUTPUT, {
      context: {
        helpVerbs,
      },
      line: 'meta.help',
      step: zeroStep(),
      volume: ShowVolume.WORLD,
    });
  }

  /**
   * Load world state and/or templates from path.
   */
  public async doLoad(event: ActorCommandEvent): Promise<void> {
    const [path] = event.command.targets;

    const doneEvent = onceEvent<LoaderReadEvent>(this.event, EVENT_LOADER_DONE);
    const stateEvent = onceEvent<LoaderStateEvent>(this.event, EVENT_LOADER_STATE);
    this.event.emit(EVENT_LOADER_READ, {
      path,
    });

    const loadEvent = await Promise.race([doneEvent, stateEvent]);
    if (hasState(loadEvent)) {
      // was a state event
      const { state } = loadEvent;
      const world = mustExist(this.loadedWorlds.get(state.meta.template));

      this.logger.debug({ bundle: world.locale }, 'loading world locale bundle');
      this.event.emit(EVENT_LOCALE_BUNDLE, {
        bundle: world.locale,
        name: 'world',
      });

      this.state = state;
      this.world = world;
      mustExist(this.generator).setWorld(world);

      this.logger.debug('emitting state loaded event');
      this.event.emit(EVENT_STATE_OUTPUT, {
        context: {
          meta: state.meta,
          path,
        },
        line: 'meta.load.state',
        step: state.step,
        volume: ShowVolume.WORLD,
      });

      this.event.emit(EVENT_STATE_LOAD, {
        state: state.meta.name,
        world: state.meta.template,
      });
    } else {
      this.logger.debug({ loadEvent }, 'path read event received first');
      this.event.emit(EVENT_STATE_OUTPUT, {
        context: {
          path,
        },
        line: 'meta.load.missing',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
    }
  }

  /**
   * Leave the state step loop.
   */
  public async doQuit(): Promise<void> {
    return this.stepQuit('meta.quit', {}, [STAT_SCORE]);
  }

  public async doSave(event: ActorCommandEvent): Promise<void> {
    if (isNone(this.state)) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        line: 'meta.save.missing',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
      return;
    }

    const state = this.state;
    const world = mustExist(this.loadedWorlds.get(state.meta.template));

    const data: DataFile = {
      state,
      worlds: [world],
    };

    const [path] = event.command.targets;
    const pendingSave = onceEvent<LoaderReadEvent>(this.event, EVENT_LOADER_DONE);

    this.event.emit(EVENT_LOADER_SAVE, {
      data,
      path,
    });

    const save = await pendingSave;

    this.event.emit(EVENT_STATE_OUTPUT, {
      context: {
        meta: state.meta,
        path: save.path,
      },
      line: 'meta.save.state',
      step: state.step,
      volume: ShowVolume.WORLD,
    });
  }

  /**
   * Perform the next world state step.
   */
  public async doStep(event: ActorCommandEvent): Promise<void> {
    const { actor, command } = event;

    // if there is no world state, there won't be an actor, but this error is more informative
    if (isNone(actor) || isNone(this.state)) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        line: 'meta.step.missing',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
      return;
    }

    this.commandBuffer.push(actor, command);
    this.logger.debug({
      actor: actor.meta.id,
      left: this.commandQueue.remaining().map((it) => it.meta.id),
      size: this.commandQueue.size,
      verb: command.verb,
    }, 'pushing command to queue');

    // step world after last actor acts
    if (this.commandQueue.complete(actor)) {
      this.logger.debug({
        actor: actor.meta.id,
        size: this.commandQueue.size,
        verb: command.verb,
      }, 'queue completed on command');
      const step = await this.step();
      this.event.emit(EVENT_STATE_STEP, {
        step,
      });
    }
  }

  public async doWorlds(): Promise<void> {
    for (const [_key, world] of this.loadedWorlds) {
      this.event.emit(EVENT_STATE_OUTPUT, {
        context: {
          id: world.meta.id,
          name: world.meta.name.base,
        },
        line: 'meta.world',
        step: zeroStep(),
        volume: ShowVolume.WORLD,
      });
    }
  }
  // #endregion meta commands

  // eslint-disable-next-line sonarjs/cognitive-complexity
  public async step(): Promise<StepResult> {
    if (isNone(this.state)) {
      throw new NotInitializedError('state has not been initialized');
    }

    const seen = new Set();
    const start = Date.now();

    const scope: StateScope = {
      behavior: {
        depth: async (actor) => this.commandBuffer.depth(actor),
        output: /* istanbul ignore next */ () => Promise.resolve<Array<string>>([]),
        queue: async (actor, command) => {
          this.commandBuffer.push(actor, command);
        },
        ready: async (actor) => (this.commandBuffer.depth(actor) > 0),
      },
      data: new Map(),
      random: this.random,
      state: {
        create: /* istanbul ignore next */ (id, type, target) => this.stepCreate(id, type, target),
        enter: /* istanbul ignore next */ (target) => this.stepEnter(target),
        find: /* istanbul ignore next */ (search) => this.stepFind(search),
        move: /* istanbul ignore next */ (target, context) => this.stepMove(target, context),
        quit: /* istanbul ignore next */ (msg, context, stats) => this.stepQuit(msg, context, stats),
        show: /* istanbul ignore next */ (msg, context, volume, source) => this.stepShow(msg, context, volume, source),
        update: /* istanbul ignore next */ (entity, changes) => this.stepUpdate(entity, changes),
      },
      step: this.state.step,
    };

    for (const room of this.state.rooms) {
      if (seen.has(room.meta.id) === false) {
        seen.add(room.meta.id);

        const roomSource: StateSource = {
          room,
        };

        await this.script.invoke(room, SIGNAL_STEP, {
          ...scope,
          room,
          source: roomSource,
        });

        for (const actor of room.actors) {
          if (seen.has(actor.meta.id) === false) {
            seen.add(actor.meta.id);

            const actorSource: StateSource = {
              actor,
              room,
            };

            const command = await this.getActorCommand(actor);
            await this.script.invoke(actor, SIGNAL_STEP, {
              ...scope,
              actor,
              command,
              room,
              source: actorSource,
            });

            for (const item of actor.items) {
              if (seen.has(item.meta.id) === false) {
                seen.add(item.meta.id);
                await this.script.invoke(item, SIGNAL_STEP, {
                  ...scope,
                  actor,
                  item,
                  room,
                  source: actorSource,
                });
              }
            }
          }

          for (const item of room.items) {
            if (seen.has(item.meta.id) === false) {
              seen.add(item.meta.id);
              await this.script.invoke(item, SIGNAL_STEP, {
                ...scope,
                item,
                room,
                source: roomSource,
              });
            }
          }

          for (const portal of room.portals) {
            if (seen.has(portal.meta.id) === false) {
              seen.add(portal.meta.id);
              await this.script.invoke(portal, SIGNAL_STEP, {
                ...scope,
                portal,
                room,
                source: roomSource,
              });
            }
          }
        }
      }
    }

    const spent = Date.now() - start;
    this.state.step.turn += 1;
    this.state.step.time += spent;
    this.logger.debug({
      seen: seen.size,
      spent,
      step: this.state.step,
    }, 'finished world state step');

    await this.broadcastChanges(this.state.rooms);

    return {
      time: this.state.step.time,
      turn: this.state.step.turn,
    };
  }

  // #region state access callbacks
  /**
   * Handler for a room change from the state helper.
   */
  public async stepCreate<TType extends WorldEntityType>(id: string, type: TType, target: StateSource): Promise<Immutable<EntityForType<TType>>> {
    const generator = mustExist(this.generator);
    const world = mustExist(this.world);

    switch (type) {
      case ACTOR_TYPE: {
        const template = findByBaseId(world.templates.actors, id);
        const actor = await generator.createActor(template);

        await this.stepUpdate(target.room, {
          actors: [...target.room.actors, actor],
        });

        await this.stepEnter({
          actor,
          room: target.room,
        });

        return actor as Immutable<EntityForType<TType>>; // not a cast, but the compiler doesn't know
      }
      case ITEM_TYPE: {
        const template = findByBaseId(world.templates.items, id);
        const item = await generator.createItem(template);
        const targetEntity = mustDefault<Immutable<Actor | Room>>(target.actor, target.room);

        await this.stepUpdate(targetEntity, {
          items: [...targetEntity.items, item],
        });

        // TODO: fire get signal

        return item as Immutable<EntityForType<TType>>;
      }
      case PORTAL_TYPE:
      case ROOM_TYPE:
      default:
        throw new InvalidArgumentError('only actors and items can be created');
    }
  }

  public async stepEnter(target: StateSource): Promise<void> {
    const generator = mustExist(this.generator);
    const state = mustExist(this.state);
    // get a mutable reference and ensure the room still exists
    const room = mustFind(state.rooms, (it) => it.meta.id === target.room.meta.id);

    if (doesExist(target.actor) && target.actor.source === ActorSource.PLAYER) {
      const rooms = await generator.populateRoom(room, state.rooms, state.world.depth);
      this.logger.debug({ rooms }, 'adding new rooms');
      if (rooms.length > 0) {
        state.rooms.push(...rooms);
      }
    }

    return this.broadcastChanges(state.rooms);
  }

  public async stepFind<TType extends WorldEntityType>(search: SearchFilter<TType>): Promise<Array<Immutable<EntityForType<TType>>>> {
    const state = mustExist(this.state);
    return findMatching(state.rooms, search);
  }

  public async stepMove(target: EntityTransfer, context: ScriptContext): Promise<void> {
    const transfer = mustExist(this.transfer);

    if (isActorTransfer(target)) {
      return transfer.moveActor(target, context);
    }

    if (isItemTransfer(target)) {
      return transfer.moveItem(target, context);
    }

    if (isRoomTransfer(target)) {
      return transfer.moveRoom(target, context);
    }

    throw new ScriptTargetError('move target must be an actor or item');
  }

  public async stepQuit(line: string, context?: LocaleContext, /* istanbul ignore next */ stats: Array<string> = []): Promise<void> {
    this.event.emit(EVENT_STATE_QUIT, {
      line,
      context,
      stats,
    });
  }

  /**
   * Handler for a line of input from the state helper.
   */
  public async stepShow(source: StateSource, line: string, context?: LocaleContext, volume: ShowVolume = ShowVolume.SELF): Promise<void> {
    this.event.emit(EVENT_STATE_OUTPUT, {
      line,
      context,
      source,
      step: mustExist(this.state).step,
      volume,
    });
  }

  /**
   * This is a little bit silly for local state, but in a network model, is needed to forward the changes to the
   * server for validation and broadcast.
   */
  public async stepUpdate<TEntity extends WorldEntity>(entity: Immutable<TEntity>, changes: Partial<Immutable<TEntity>>): Promise<void> {
    Object.assign(entity, changes);
  }

  // #endregion state access callbacks

  /**
   * Emit changed rooms to relevant actors.
   *
   * @todo only emit changed rooms
   * @todo do not double-loop
   * @todo skip actors who already have commands queued
   */
  protected async broadcastChanges(rooms: Array<Room>): Promise<void> {
    this.logger.debug('queueing actors');
    this.commandQueue.clear();
    for (const room of rooms) {
      for (const actor of room.actors) {
        this.commandQueue.add(actor);
        this.logger.debug({
          actor,
          size: this.commandQueue.size,
        }, 'adding actor to queue');
      }
    }

    this.logger.debug('broadcasting room changes');
    for (const room of rooms) {
      for (const actor of room.actors) {
        this.event.emit(EVENT_STATE_ROOM, {
          actor,
          room,
        });
      }
    }
  }

  /**
   * Get the next queued command for an actor.
   */
  protected async getActorCommand(actor: Actor): Promise<Command> {
    const command = this.commandBuffer.pop(actor);

    if (doesExist(command)) {
      return command;
    } else {
      throw new Error('actor has not queued a command: ' + actor.meta.id);
    }
  }
}