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.
// 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);
}
}
