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/player.ts
// Port of BrowserQuest server/js/player.js (MPL-2.0).
// The trust model is the original one: the client reports HIT/HURT/AGGRO and the
// world computes damage. Message handling order and rules are unchanged.
import * as Formulas from '../shared/formulas.js';
import * as Properties from '../shared/properties.js';
import { check } from '../shared/format.js';
import {
  Entities,
  Messages,
  getMessageTypeAsString,
  isArmor,
  isHealingItem,
  isItem,
  isWeapon,
} from '../shared/gametypes.js';
import { randomOrientation, sanitize } from '../shared/utils.js';
import { Character, Chest, Item, Mob, type Message } from './entities.js';
import type { Checkpoint } from './map.js';
import type { World } from './world.js';

/** The world's view of one connected client. */
export interface Connection {
  readonly id: number;
  send(messages: Message[]): void;
  close(reason: string): void;
}

const IDLE_TIMEOUT_MS = 1000 * 60 * 15;

export class Player extends Character {
  name = '';
  /** Self-claimed Nostr pubkey (hex). Display only; never used for authority. */
  nostrPubkey = '';
  armor: number = Entities.CLOTHARMOR;
  weapon: number = Entities.SWORD1;
  armorLevel = 1;
  weaponLevel = 1;
  hasEnteredGame = false;
  isDead = false;
  haters = new Map<number, Mob>();
  lastCheckpoint: Checkpoint | null = null;
  private disconnectTimeout: number | null = null;
  private firepotionTimeout: number | null = null;
  private requestPositionCallback: (() => { x: number; y: number }) | null = null;
  moveCallback: ((x: number, y: number) => void) | null = null;
  lootMoveCallback: ((x: number, y: number) => void) | null = null;
  zoneCallback: (() => void) | null = null;
  broadcastCallback: ((message: Message, ignoreSelf: boolean) => void) | null = null;
  broadcastZoneCallback: ((message: Message, ignoreSelf: boolean) => void) | null = null;
  exitCallback: (() => void) | null = null;

  constructor(
    readonly connection: Connection,
    private readonly server: World,
  ) {
    super(connection.id, 'player', Entities.WARRIOR, 0, 0);
  }

  /** Called by the transport for every message received from this client. */
  receive(message: unknown): void {
    if (!check(message)) {
      const type = Array.isArray(message) ? Number(message[0]) : NaN;
      this.connection.close(`Invalid ${getMessageTypeAsString(type)} message format`);
      return;
    }
    const action = message[0] as number;

    if (!this.hasEnteredGame && action !== Messages.HELLO) {
      // HELLO must be the first message
      this.connection.close('Invalid handshake message');
      return;
    }
    if (this.hasEnteredGame && !this.isDead && action === Messages.HELLO) {
      // HELLO can be sent only once
      this.connection.close('Cannot initiate handshake twice');
      return;
    }

    this.resetTimeout();

    switch (action) {
      case Messages.HELLO:
        return this.handleHello(message);
      case Messages.WHO:
        return this.server.pushSpawnsToPlayer(this, message.slice(1) as number[]);
      case Messages.ZONE:
        return this.zoneCallback?.();
      case Messages.CHAT:
        return this.handleChat(message[1] as string);
      case Messages.MOVE:
        return this.handleMove(message[1] as number, message[2] as number);
      case Messages.LOOTMOVE:
        return this.handleLootMove(message[1] as number, message[2] as number, message[3] as number);
      case Messages.AGGRO:
        if (this.moveCallback) this.server.handleMobHate(message[1] as number, this.id, 5);
        return;
      case Messages.ATTACK: {
        const mob = this.server.getEntityById(message[1] as number);
        if (mob) {
          this.setTarget(mob);
          this.server.broadcastAttacker(this);
        }
        return;
      }
      case Messages.HIT:
        return this.handleHit(message[1] as number);
      case Messages.HURT:
        return this.handleHurt(message[1] as number);
      case Messages.LOOT:
        return this.handleLoot(message[1] as number);
      case Messages.TELEPORT:
        return this.handleTeleport(message[1] as number, message[2] as number);
      case Messages.OPEN: {
        const chest = this.server.getEntityById(message[1] as number);
        if (chest instanceof Chest) this.server.handleOpenedChest(chest);
        return;
      }
      case Messages.CHECK: {
        const checkpoint = this.server.map.getCheckpoint(message[1] as number);
        if (checkpoint) this.lastCheckpoint = checkpoint;
        return;
      }
    }
  }

  private handleHello(message: unknown[]): void {
    const name = sanitize(message[1] as string);
    // If name was cleared by the sanitizer, give a default name.
    this.name = name === '' ? 'lorem ipsum' : name.substring(0, 15);
    const pubkey = typeof message[4] === 'string' ? message[4] : '';
    this.nostrPubkey = /^[0-9a-f]{64}$/.test(pubkey) ? pubkey : '';

    this.kind = Entities.WARRIOR;
    this.equipArmor(message[2] as number);
    this.equipWeapon(message[3] as number);
    this.orientation = randomOrientation();
    this.updateHitPoints();
    this.updatePosition();

    this.server.addPlayer(this);
    this.server.enterCallback(this);

    this.send([[Messages.WELCOME, this.id, this.name, this.x, this.y, this.hitPoints]]);
    this.hasEnteredGame = true;
    this.isDead = false;
  }

  private handleChat(text: string): void {
    const msg = sanitize(text);
    // Sanitized messages may become empty. No need to broadcast empty chat messages.
    if (msg !== '') {
      // The original sent chat to the speaker's own group only, which matched its
      // fixed screen. With the port's fitted camera a visible neighbour can be in an
      // adjacent group, so chat reaches everyone who can see the speaker (the same
      // adjacent groups that receive their moves).
      this.broadcast([Messages.CHAT, this.id, msg.substring(0, 60)], false);
    }
  }

  private handleMove(x: number, y: number): void {
    if (this.moveCallback && this.server.isValidPosition(x, y)) {
      this.setPosition(x, y);
      this.clearTarget();
      this.broadcast([Messages.MOVE, this.id, this.x, this.y]);
      this.moveCallback(this.x, this.y);
    }
  }

  private handleLootMove(x: number, y: number, itemId: number): void {
    if (this.lootMoveCallback) {
      this.setPosition(x, y);
      const item = this.server.getEntityById(itemId);
      if (item) {
        this.clearTarget();
        this.broadcast([Messages.LOOTMOVE, this.id, item.id]);
        this.lootMoveCallback(this.x, this.y);
      }
    }
  }

  private handleHit(mobId: number): void {
    const mob = this.server.getEntityById(mobId);
    if (mob instanceof Mob) {
      const dmg = Formulas.dmg(this.weaponLevel, mob.armorLevel);
      if (dmg > 0) {
        mob.receiveDamage(dmg);
        this.server.handleMobHate(mob.id, this.id, dmg);
        this.server.handleHurtEntity(mob, this, dmg);
      }
    }
  }

  private handleHurt(mobId: number): void {
    const mob = this.server.getEntityById(mobId);
    if (mob instanceof Mob && this.hitPoints > 0) {
      this.hitPoints -= Formulas.dmg(mob.weaponLevel, this.armorLevel);
      this.server.handleHurtEntity(this);

      if (this.hitPoints <= 0) {
        this.isDead = true;
        if (this.firepotionTimeout !== null) {
          this.server.scheduler.cancel(this.firepotionTimeout);
          this.firepotionTimeout = null;
        }
      }
    }
  }

  private handleLoot(itemId: number): void {
    const item = this.server.getEntityById(itemId);
    if (!(item instanceof Item)) return;
    const kind = item.kind;
    if (!isItem(kind)) return;

    this.broadcast(item.despawn());
    this.server.removeEntity(item);

    if (kind === Entities.FIREPOTION) {
      this.updateHitPoints();
      this.broadcast(this.equip(Entities.FIREFOX));
      if (this.firepotionTimeout !== null) this.server.scheduler.cancel(this.firepotionTimeout);
      this.firepotionTimeout = this.server.scheduler.later(() => {
        this.broadcast(this.equip(this.armor)); // return to normal after 15 sec
        this.firepotionTimeout = null;
      }, 15000);
      this.send([[Messages.HP, this.maxHitPoints]]);
    } else if (isHealingItem(kind)) {
      const amount = kind === Entities.FLASK ? 40 : 100;
      if (!this.hasFullHealth()) {
        this.regenHealthBy(amount);
        this.server.pushToPlayer(this, this.health());
      }
    } else if (isArmor(kind) || isWeapon(kind)) {
      this.equipItem(item);
      this.broadcast(this.equip(kind));
    }
  }

  private handleTeleport(x: number, y: number): void {
    if (this.server.isValidPosition(x, y)) {
      this.setPosition(x, y);
      this.clearTarget();
      this.broadcast([Messages.TELEPORT, this.id, this.x, this.y]);
      this.server.handlePlayerVanish(this);
      this.server.pushRelevantEntityListTo(this);
    }
  }

  /** The transport calls this when the client goes away. */
  disconnect(): void {
    if (this.firepotionTimeout !== null) this.server.scheduler.cancel(this.firepotionTimeout);
    if (this.disconnectTimeout !== null) this.server.scheduler.cancel(this.disconnectTimeout);
    this.firepotionTimeout = null;
    this.disconnectTimeout = null;
    this.exitCallback?.();
  }

  destroy(): void {
    this.forEachAttacker((mob) => mob.clearTarget());
    this.attackers.clear();
    for (const mob of this.haters.values()) mob.forgetPlayer(this.id);
    this.haters.clear();
  }

  getState(): Message {
    const state: Message = [...this.baseState(), this.name, this.orientation, this.armor, this.weapon];
    if (this.target) state.push(this.target);
    return state;
  }

  send(messages: Message[]): void {
    this.connection.send(messages);
  }

  broadcast(message: Message, ignoreSelf = true): void {
    this.broadcastCallback?.(message, ignoreSelf);
  }

  broadcastToZone(message: Message, ignoreSelf = true): void {
    this.broadcastZoneCallback?.(message, ignoreSelf);
  }

  equip(kind: number): Message {
    return [Messages.EQUIP, this.id, kind];
  }

  addHater(mob: Mob): void {
    if (!this.haters.has(mob.id)) this.haters.set(mob.id, mob);
  }

  removeHater(mob: Mob): void {
    this.haters.delete(mob.id);
  }

  equipArmor(kind: number): void {
    this.armor = kind;
    this.armorLevel = Properties.getArmorLevel(kind);
  }

  equipWeapon(kind: number): void {
    this.weapon = kind;
    this.weaponLevel = Properties.getWeaponLevel(kind);
  }

  equipItem(item: Item): void {
    if (isArmor(item.kind)) {
      this.equipArmor(item.kind);
      this.updateHitPoints();
      this.send([[Messages.HP, this.maxHitPoints]]);
    } else if (isWeapon(item.kind)) {
      this.equipWeapon(item.kind);
    }
  }

  updateHitPoints(): void {
    this.resetHitPoints(Formulas.hp(this.armorLevel));
  }

  updatePosition(): void {
    if (this.requestPositionCallback) {
      const pos = this.requestPositionCallback();
      this.setPosition(pos.x, pos.y);
    }
  }

  onRequestPosition(callback: () => { x: number; y: number }): void {
    this.requestPositionCallback = callback;
  }

  private resetTimeout(): void {
    if (this.disconnectTimeout !== null) this.server.scheduler.cancel(this.disconnectTimeout);
    this.disconnectTimeout = this.server.scheduler.later(() => {
      this.connection.close('timeout');
    }, IDLE_TIMEOUT_MS);
  }
}