Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/sim/world.ts
// Port of BrowserQuest server/js/worldserver.js (MPL-2.0).
// Runs inside one browser tab: the shard host for multiplayer, or the local tab in solo play.
import { Messages, getKindAsString, getKindFromString, isItem, isMob, isNpc, Entities } from '../shared/gametypes.js';
import { Properties } from '../shared/properties.js';
import { random } from '../shared/utils.js';
import { ChestArea, MobArea } from './areas.js';
import { Character, Chest, Entity, Item, Mob, Npc, type Message } from './entities.js';
import { WorldMap, type ServerMapData } from './map.js';
import { Player, type Connection } from './player.js';
import { Scheduler } from './scheduler.js';

interface Group {
  entities: Map<number, Entity>;
  players: number[];
  incoming: Entity[];
}

export class World {
  readonly ups = 50;
  readonly scheduler = new Scheduler();
  readonly map: WorldMap;
  readonly entities = new Map<number, Entity>();
  readonly players = new Map<number, Player>();
  readonly mobs = new Map<number, Mob>();
  readonly items = new Map<number, Item>();
  readonly npcs = new Map<number, Npc>();
  readonly mobAreas: MobArea[] = [];
  readonly chestAreas: ChestArea[] = [];
  readonly groups = new Map<string, Group>();
  private readonly outgoingQueues = new Map<number, Message[]>();
  private itemCount = 0;
  playerCount = 0;
  private zoneGroupsReady = false;
  private connectionCounter = 0;
  onPopulationChange: ((count: number) => void) | null = null;

  constructor(mapData: ServerMapData) {
    this.map = new WorldMap(mapData);
  }

  run(): void {
    this.initZoneGroups();

    // Populate all mob "roaming" areas
    for (const a of this.map.mobAreas) {
      const area = new MobArea(a.id, a.nb, a.type, a.x, a.y, a.width, a.height, this);
      area.spawnMobs();
      this.mobAreas.push(area);
    }

    // Create all chest areas
    for (const a of this.map.chestAreas) {
      const area = new ChestArea(0, a.x, a.y, a.w, a.h, a.tx, a.ty, a.i, this);
      this.chestAreas.push(area);
      area.onEmpty(() => this.handleEmptyChestArea(area));
    }

    // Spawn static chests
    for (const chest of this.map.staticChests) {
      this.addStaticItem(this.createChest(chest.x, chest.y, chest.i));
    }

    this.spawnStaticEntities();

    // Set maximum number of entities contained in each chest area
    for (const area of this.chestAreas) area.setNumberOfEntities(area.entities.length);

    const regenCount = this.ups * 2;
    let updateCount = 0;
    this.scheduler.every(() => {
      this.processGroups();
      this.processQueues();
      if (updateCount < regenCount) {
        updateCount += 1;
      } else {
        this.onRegenTick();
        updateCount = 0;
      }
    }, 1000 / this.ups);
  }

  dispose(): void {
    this.scheduler.dispose();
  }

  /** Allocate a connection id with the original '5' + random(99) + counter shape. */
  nextConnectionId(): number {
    return parseInt(`5${random(99)}${this.connectionCounter++}`, 10);
  }

  /**
   * A client connected: create its session. The client then sends HELLO.
   * `resume` (host migration / shard switch) places the first spawn at the
   * player's last position instead of a starting area, when that tile is valid.
   */
  connect(connection: Connection, resume?: { x: number; y: number; checkpoint?: number }): Player {
    const player = new Player(connection, this);
    let resumeAt = resume && this.isValidPosition(resume.x, resume.y) ? { x: resume.x, y: resume.y } : null;
    if (resume?.checkpoint !== undefined) player.lastCheckpoint = this.map.getCheckpoint(resume.checkpoint) ?? null;
    player.onRequestPosition(() => {
      if (resumeAt) {
        const pos = resumeAt;
        resumeAt = null;
        return pos;
      }
      return player.lastCheckpoint
        ? player.lastCheckpoint.getRandomPosition()
        : this.map.getRandomStartingPosition();
    });
    return player;
  }

  enterCallback(player: Player): void {
    if (!player.hasEnteredGame) this.incrementPlayerCount();

    // Number of players in this world
    this.pushToPlayer(player, [Messages.POPULATION, this.playerCount, this.playerCount]);
    this.pushRelevantEntityListTo(player);

    // The original updated a player's zone group only on the client's ZONE message,
    // sent when its fixed 30x14 camera flipped screens, which lined up with the 28x12
    // groups. The port's camera is fitted to the frame, so membership follows the
    // player's position on every move instead (ZONE still works and is harmless).
    const updateZone = (): void => {
      if (this.handleEntityGroupMembership(player)) {
        this.pushToPreviousGroups(player, [Messages.DESTROY, player.id]);
        this.pushRelevantEntityListTo(player);
      }
    };

    const moveCallback = (): void => {
      updateZone();
      player.forEachAttacker((attacker) => {
        const mob = attacker as Mob;
        // Fix from the community fork: drop a stale link to a mob that lost its target.
        if (mob.target === null) {
          player.removeAttacker(mob);
          return;
        }
        const target = this.getEntityById(mob.target);
        if (target) {
          const pos = this.findPositionNextTo(mob, target);
          if (mob.distanceToSpawningPoint(pos.x, pos.y) > 50) {
            mob.clearTarget();
            mob.forgetEveryone();
            player.removeAttacker(mob);
          } else {
            this.moveEntity(mob, pos.x, pos.y);
          }
        }
      });
    };

    player.moveCallback = moveCallback;
    player.lootMoveCallback = moveCallback;

    player.zoneCallback = updateZone;

    player.broadcastCallback = (message, ignoreSelf) => {
      this.pushToAdjacentGroups(player.group, message, ignoreSelf ? player.id : null);
    };

    player.broadcastZoneCallback = (message, ignoreSelf) => {
      if (player.group) this.pushToGroup(player.group, message, ignoreSelf ? player.id : null);
    };

    player.exitCallback = () => {
      this.removePlayer(player);
      this.decrementPlayerCount();
    };
  }

  private onRegenTick(): void {
    this.forEachCharacter((character) => {
      if (!character.hasFullHealth()) {
        character.regenHealthBy(Math.floor(character.maxHitPoints / 25));
        if (character instanceof Player) this.pushToPlayer(character, character.regen());
      }
    });
  }

  pushRelevantEntityListTo(player: Player): void {
    const group = player.group ? this.groups.get(player.group) : undefined;
    if (group) {
      const ids = [...group.entities.keys()].filter((id) => id !== player.id);
      this.pushToPlayer(player, [Messages.LIST, ...ids]);
    }
  }

  pushSpawnsToPlayer(player: Player, ids: number[]): void {
    for (const id of ids) {
      const entity = this.getEntityById(id);
      if (entity) for (const message of this.spawnMessages(entity)) this.pushToPlayer(player, message);
    }
  }

  /** SPAWN plus, for players with a Nostr key, the port's PLAYERINFO extension. */
  private spawnMessages(entity: Entity): Message[] {
    const messages = [entity.spawn()];
    if (entity instanceof Player && entity.nostrPubkey) {
      messages.push([Messages.PLAYERINFO, entity.id, entity.nostrPubkey]);
    }
    return messages;
  }

  pushToPlayer(player: Player | undefined, message: Message): void {
    if (player && this.outgoingQueues.has(player.id)) {
      this.outgoingQueues.get(player.id)!.push(message);
    }
  }

  pushToGroup(groupId: string, message: Message, ignoredPlayer: number | null = null): void {
    const group = this.groups.get(groupId);
    if (!group) return;
    for (const playerId of group.players) {
      if (playerId !== ignoredPlayer) this.pushToPlayer(this.players.get(playerId), message);
    }
  }

  pushToAdjacentGroups(groupId: string | null, message: Message, ignoredPlayer: number | null = null): void {
    this.map.forEachAdjacentGroup(groupId, (id) => this.pushToGroup(id, message, ignoredPlayer));
  }

  pushToPreviousGroups(player: Player, message: Message): void {
    // Push this message to all groups which are not going to be updated anymore,
    // since the player left them.
    for (const id of player.recentlyLeftGroups) this.pushToGroup(id, message);
    player.recentlyLeftGroups = [];
  }

  pushBroadcast(message: Message, ignoredPlayer: number | null = null): void {
    for (const [id, queue] of this.outgoingQueues) {
      if (id !== ignoredPlayer) queue.push(message);
    }
  }

  private processQueues(): void {
    for (const [id, queue] of this.outgoingQueues) {
      if (queue.length > 0) {
        this.players.get(id)?.connection.send(queue);
        this.outgoingQueues.set(id, []);
      }
    }
  }

  addEntity(entity: Entity): void {
    this.entities.set(entity.id, entity);
    this.handleEntityGroupMembership(entity);
  }

  removeEntity(entity: Entity): void {
    this.entities.delete(entity.id);
    this.mobs.delete(entity.id);
    this.items.delete(entity.id);

    if (entity instanceof Mob) {
      this.clearMobAggroLink(entity);
      this.clearMobHateLinks(entity);
    }

    entity.destroy();
    this.removeFromGroups(entity);
  }

  addPlayer(player: Player): void {
    this.addEntity(player);
    this.players.set(player.id, player);
    this.outgoingQueues.set(player.id, []);
  }

  removePlayer(player: Player): void {
    player.broadcast(player.despawn());
    this.removeEntity(player);
    this.players.delete(player.id);
    this.outgoingQueues.delete(player.id);
  }

  addMob(mob: Mob): void {
    this.addEntity(mob);
    this.mobs.set(mob.id, mob);
  }

  addNpc(kind: number, x: number, y: number): Npc {
    const npc = new Npc(`8${x}${y}`, kind, x, y);
    this.addEntity(npc);
    this.npcs.set(npc.id, npc);
    return npc;
  }

  addItem(item: Item): Item {
    this.addEntity(item);
    this.items.set(item.id, item);
    return item;
  }

  createItem(kind: number, x: number, y: number): Item {
    const id = `9${this.itemCount++}`;
    return kind === Entities.CHEST
      ? new Chest(id, x, y, this.scheduler)
      : new Item(id, kind, x, y, this.scheduler);
  }

  createChest(x: number, y: number, items: number[]): Chest {
    const chest = this.createItem(Entities.CHEST, x, y) as Chest;
    chest.setItems(items);
    return chest;
  }

  addStaticItem(item: Item): Item {
    item.isStatic = true;
    item.onRespawn(() => this.addStaticItem(item));
    return this.addItem(item);
  }

  addItemFromChest(kind: number, x: number, y: number): Item {
    const item = this.createItem(kind, x, y);
    item.isFromChest = true;
    return this.addItem(item);
  }

  /** The mob will no longer be registered as an attacker of its current target. */
  clearMobAggroLink(mob: Mob): void {
    if (mob.target !== null) {
      const player = this.getEntityById(mob.target);
      if (player instanceof Character) player.removeAttacker(mob);
    }
  }

  clearMobHateLinks(mob: Mob): void {
    for (const obj of mob.hatelist) {
      const player = this.getEntityById(obj.id);
      if (player instanceof Player) player.removeHater(mob);
    }
  }

  forEachPlayer(callback: (p: Player) => void): void {
    for (const player of [...this.players.values()]) callback(player);
  }

  forEachMob(callback: (m: Mob) => void): void {
    for (const mob of [...this.mobs.values()]) callback(mob);
  }

  forEachCharacter(callback: (c: Character) => void): void {
    this.forEachPlayer(callback);
    this.forEachMob(callback);
  }

  handleMobHate(mobId: number, playerId: number, hatePoints: number): void {
    const mob = this.getEntityById(mobId);
    const player = this.getEntityById(playerId);
    if (player instanceof Player && mob instanceof Mob) {
      mob.increaseHateFor(playerId, hatePoints);
      player.addHater(mob);
      // only choose a target if still alive
      if (mob.hitPoints > 0) this.chooseMobTarget(mob);
    }
  }

  chooseMobTarget(mob: Mob, hateRank?: number): void {
    const hatedId = mob.getHatedPlayerId(hateRank);
    const player = hatedId !== undefined ? this.getEntityById(hatedId) : undefined;

    // If the mob is not already attacking the player, create an attack link between them.
    if (player instanceof Player && !player.attackers.has(mob.id)) {
      this.clearMobAggroLink(mob);
      player.addAttacker(mob);
      mob.setTarget(player);
      this.broadcastAttacker(mob);
    }
  }

  getEntityById(id: number): Entity | undefined {
    return this.entities.get(id);
  }

  broadcastAttacker(character: Character): void {
    this.pushToAdjacentGroups(character.group, character.attack(), character.id);

    // Called when an entity is attacked by another entity
    const target = character.target !== null ? this.getEntityById(character.target) : undefined;
    if (target && character instanceof Mob) {
      const pos = this.findPositionNextTo(character, target);
      this.moveEntity(character, pos.x, pos.y);
    }
  }

  handleHurtEntity(entity: Character, attacker?: Player, damage?: number): void {
    if (entity instanceof Player) {
      // A player is only aware of his own hitpoints
      this.pushToPlayer(entity, entity.health());
    }

    if (entity instanceof Mob) {
      // Let the mob's attacker (player) know how much damage was inflicted
      this.pushToPlayer(attacker, [Messages.DAMAGE, entity.id, damage ?? 0]);
    }

    // If the entity is about to die
    if (entity.hitPoints <= 0) {
      if (entity instanceof Mob) {
        const item = this.getDroppedItem(entity);
        this.pushToPlayer(attacker, [Messages.KILL, entity.kind]);
        // Despawn must be enqueued before the item drop
        this.pushToAdjacentGroups(entity.group, entity.despawn());
        if (item) {
          this.pushToAdjacentGroups(entity.group, entity.drop(item));
          this.handleItemDespawn(item);
        }
      }

      if (entity instanceof Player) {
        this.handlePlayerVanish(entity);
        this.pushToAdjacentGroups(entity.group, entity.despawn());
      }

      this.removeEntity(entity);
    }
  }

  private spawnStaticEntities(): void {
    let count = 0;
    for (const [tid, kindName] of Object.entries(this.map.staticEntities)) {
      const kind = getKindFromString(kindName);
      if (kind === undefined) continue;
      const pos = this.map.tileIndexToGridPosition(Number(tid));

      if (isNpc(kind)) this.addNpc(kind, pos.x + 1, pos.y);

      if (isMob(kind)) {
        const mob = new Mob(`7${kind}${count++}`, kind, pos.x + 1, pos.y, this.scheduler);
        mob.onRespawn(() => {
          mob.isDead = false;
          this.addMob(mob);
          if (mob.area instanceof ChestArea) mob.area.addToArea(mob);
        });
        mob.onMove(this.onMobMoveCallback.bind(this));
        this.addMob(mob);
        this.tryAddingMobToChestArea(mob);
      }

      if (isItem(kind)) this.addStaticItem(this.createItem(kind, pos.x + 1, pos.y));
    }
  }

  isValidPosition(x: unknown, y: unknown): x is number {
    return (
      typeof x === 'number' &&
      typeof y === 'number' &&
      Number.isFinite(x) &&
      Number.isFinite(y) &&
      !this.map.isOutOfBounds(x, y) &&
      !this.map.isColliding(x, y)
    );
  }

  handlePlayerVanish(player: Player): void {
    const previousAttackers: Mob[] = [];

    // When a player dies or teleports, all of his attackers go and attack their second most hated player.
    player.forEachAttacker((attacker) => {
      const mob = attacker as Mob;
      previousAttackers.push(mob);
      this.chooseMobTarget(mob, 2);
    });

    for (const mob of previousAttackers) {
      player.removeAttacker(mob);
      mob.clearTarget();
      mob.forgetPlayer(player.id, 1000);
    }

    this.handleEntityGroupMembership(player);
  }

  private setPlayerCount(count: number): void {
    this.playerCount = count;
    this.onPopulationChange?.(count);
  }

  private incrementPlayerCount(): void {
    this.setPlayerCount(this.playerCount + 1);
  }

  private decrementPlayerCount(): void {
    if (this.playerCount > 0) this.setPlayerCount(this.playerCount - 1);
  }

  getDroppedItem(mob: Mob): Item | null {
    const drops = Properties[getKindAsString(mob.kind) ?? '']?.drops ?? {};
    const v = random(100);
    let p = 0;
    for (const [itemName, percentage] of Object.entries(drops)) {
      p += percentage;
      if (v <= p) {
        return this.addItem(this.createItem(getKindFromString(itemName)!, mob.x, mob.y));
      }
    }
    return null;
  }

  onMobMoveCallback(mob: Mob): void {
    this.pushToAdjacentGroups(mob.group, [Messages.MOVE, mob.id, mob.x, mob.y]);
    this.handleEntityGroupMembership(mob);
  }

  findPositionNextTo(entity: Entity, target: Entity): { x: number; y: number } {
    // The original loops until valid; bound it so a fully walled-in target can't hang the tab.
    for (let i = 0; i < 64; i += 1) {
      const pos = entity.getPositionNextTo(target);
      if (this.isValidPosition(pos.x, pos.y)) return pos;
    }
    return { x: entity.x, y: entity.y };
  }

  private initZoneGroups(): void {
    this.map.forEachGroup((id) => {
      this.groups.set(id, { entities: new Map(), players: [], incoming: [] });
    });
    this.zoneGroupsReady = true;
  }

  removeFromGroups(entity: Entity): string[] {
    const oldGroups: string[] = [];
    if (entity.group) {
      const group = this.groups.get(entity.group);
      if (group && entity instanceof Player) {
        group.players = group.players.filter((id) => id !== entity.id);
      }
      this.map.forEachAdjacentGroup(entity.group, (id) => {
        const g = this.groups.get(id);
        if (g?.entities.has(entity.id)) {
          g.entities.delete(entity.id);
          oldGroups.push(id);
        }
      });
      entity.group = null;
    }
    return oldGroups;
  }

  /**
   * Registers an entity as "incoming" into several groups, meaning that it just entered them.
   * All players inside these groups will receive a Spawn message when processGroups is called.
   */
  private addAsIncomingToGroup(entity: Entity, groupId: string): void {
    const isChest = entity instanceof Chest;
    const isItemEntity = entity instanceof Item;
    const isDroppedItem = isItemEntity && !entity.isStatic && !entity.isFromChest;

    this.map.forEachAdjacentGroup(groupId, (id) => {
      const group = this.groups.get(id);
      // Items dropped off of mobs are handled differently via DROP messages. See handleHurtEntity.
      if (group && !group.entities.has(entity.id) && (!isItemEntity || isChest || !isDroppedItem)) {
        group.incoming.push(entity);
      }
    });
  }

  private addToGroup(entity: Entity, groupId: string): string[] {
    const newGroups: string[] = [];
    if (this.groups.has(groupId)) {
      this.map.forEachAdjacentGroup(groupId, (id) => {
        this.groups.get(id)!.entities.set(entity.id, entity);
        newGroups.push(id);
      });
      entity.group = groupId;
      if (entity instanceof Player) this.groups.get(groupId)!.players.push(entity.id);
    }
    return newGroups;
  }

  handleEntityGroupMembership(entity: Entity): boolean {
    const groupId = this.map.getGroupIdFromPosition(entity.x, entity.y);
    if (!entity.group || entity.group !== groupId) {
      this.addAsIncomingToGroup(entity, groupId);
      const oldGroups = this.removeFromGroups(entity);
      const newGroups = this.addToGroup(entity, groupId);
      if (oldGroups.length > 0) {
        entity.recentlyLeftGroups = oldGroups.filter((g) => !newGroups.includes(g));
      }
      return true;
    }
    return false;
  }

  private processGroups(): void {
    if (!this.zoneGroupsReady) return;
    for (const [id, group] of this.groups) {
      if (group.incoming.length > 0) {
        for (const entity of group.incoming) {
          for (const message of this.spawnMessages(entity)) {
            if (entity instanceof Player) this.pushToGroup(id, message, entity.id);
            else this.pushToGroup(id, message);
          }
        }
        group.incoming = [];
      }
    }
  }

  moveEntity(entity: Entity, x: number, y: number): void {
    entity.setPosition(x, y);
    this.handleEntityGroupMembership(entity);
  }

  handleItemDespawn(item: Item): void {
    item.handleDespawn({
      beforeBlinkDelay: 10000,
      blinkCallback: () => this.pushToAdjacentGroups(item.group, [Messages.BLINK, item.id]),
      blinkingDuration: 4000,
      despawnCallback: () => {
        this.pushToAdjacentGroups(item.group, [Messages.DESTROY, item.id]);
        this.removeEntity(item);
      },
    });
  }

  private handleEmptyChestArea(area: ChestArea): void {
    const chest = this.addItem(this.createChest(area.chestX, area.chestY, area.items));
    this.handleItemDespawn(chest);
  }

  handleOpenedChest(chest: Chest): void {
    this.pushToAdjacentGroups(chest.group, chest.despawn());
    this.removeEntity(chest);

    const kind = chest.getRandomItem();
    if (kind) {
      const item = this.addItemFromChest(kind, chest.x, chest.y);
      this.handleItemDespawn(item);
    }
  }

  private tryAddingMobToChestArea(mob: Mob): void {
    for (const area of this.chestAreas) {
      if (area.contains(mob)) area.addToArea(mob);
    }
  }

  updatePopulation(totalPlayers?: number): void {
    this.pushBroadcast([Messages.POPULATION, this.playerCount, totalPlayers ?? this.playerCount]);
  }
}