programming-game
Version:
The client for programming game, an mmorpg that you interact with entirely through code.
796 lines (773 loc) • 26.1 kB
text/typescript
import { constants } from "./constants";
import { getHandlers } from "./get-handlers";
import { items } from "./items";
import { recipes } from "./recipes";
import {
BatchedEvents,
Boundary,
ClientSideNPC,
ClientSidePlayer,
ClientSideUnit,
EventMap,
GameObject,
InstanceName,
Intent,
PlayersSeekingParty,
RawEvents,
OnTick,
} from "./types";
import { entries } from "./utils";
let localItems = items;
let localConstants = constants;
let localRecipes = recipes;
const isPlayerUnit = (unit?: ClientSideUnit): unit is ClientSidePlayer => {
return !!unit && unit.type === "player";
};
const isEmptyObject = (obj: object) => {
for (const _key in obj) {
if (obj.hasOwnProperty(_key)) {
return false;
}
}
return true;
};
export type OnEvent = <T extends keyof RawEvents>(
instance: InstanceName,
charId: string,
eventName: T,
evt: RawEvents[T]
) => void;
export type SetIntent = (intent: {
c: string; // character id
i: InstanceName;
intent: Intent;
unitId: string; // unit id
}) => void;
/**
* Base class for the client.
* This class cares about the events, but NOT the implementation of the events.
* Helpful for testing the server/client integration without firing up websockets.
*/
export class BaseClient {
private onTick: OnTick;
private heartBeatInterval?: NodeJS.Timeout;
private setIntent: SetIntent;
private onEvent?: OnEvent;
constructor({
onTick,
onEvent,
setIntent,
tickInterval,
}: {
onTick: OnTick;
onEvent?: OnEvent;
setIntent: SetIntent;
/**
* Controls how often onTick is called when there's no new data from the server.
*/
tickInterval: number;
}) {
this.onTick = onTick;
this.onEvent = onEvent;
this.setIntent = setIntent;
this.heartBeatInterval = setInterval(this.runOnTick, tickInterval);
}
private lastOnTick = Date.now();
protected innerState: {
gameTime: number;
instances: {
[key: string]: {
time: number;
characters: Record<
string,
{
units: Record<string, ClientSideUnit>;
gameObjects: Record<string, GameObject>;
}
>;
playersSeekingParty: PlayersSeekingParty;
boundary?: Boundary;
};
};
arenaDurations: Record<string, number>;
} = {
gameTime: 0,
instances: {},
arenaDurations: {},
};
private initializeInstance = (instance: string, charId: string) => {
this.innerState.instances[instance] ||= {
time: 0,
characters: {},
playersSeekingParty: [],
};
this.innerState.instances[instance].characters[charId] ||= {
units: {},
gameObjects: {},
};
return this.innerState.instances[instance];
};
public clearState = () => {
this.innerState = {
gameTime: 0,
instances: {},
arenaDurations: {},
};
this.heartBeatInterval && clearInterval(this.heartBeatInterval);
};
eventsHandler = (events: BatchedEvents): void => {
for (const instanceId in events) {
const eventCharMap = events[instanceId];
for (const charId in eventCharMap) {
const events = eventCharMap[charId];
this.initializeInstance(instanceId, charId);
events.forEach(([eventName, eventPayload]) => {
// it's important that this happens before the event is processed so that logging works correctly.
this.onEvent?.(
instanceId as InstanceName,
charId,
eventName,
eventPayload
);
const handler = this.socketEventHandlers[eventName];
if (handler) {
// @ts-expect-error
handler(instanceId, charId, eventPayload);
}
});
}
}
this.runOnTick();
};
public runOnTick = () => {
const elapsed = Date.now() - this.lastOnTick;
this.lastOnTick = Date.now();
this.innerState.gameTime += elapsed;
for (const instanceId in this.innerState.instances) {
const instance = this.innerState.instances[instanceId];
if (this.innerState.arenaDurations[instanceId]) {
this.innerState.arenaDurations[instanceId] -= elapsed;
}
for (const charId in instance.characters) {
const charState = instance.characters[charId];
for (const unitId in charState.units) {
const unit = charState.units[unitId];
for (const effectId in unit.statusEffects) {
const effect =
unit.statusEffects[effectId as keyof typeof unit.statusEffects]!;
effect.duration -= elapsed;
}
}
if (instanceId === "overworld" || instanceId.startsWith("instance-")) {
const char = charState.units[charId] as ClientSidePlayer;
if (!char) continue;
const intent = this.onTick({
inArena: false,
player: Object.assign(getHandlers(char), char),
boundary: instance.boundary,
instanceId,
playersSeekingParty: instance.playersSeekingParty,
items: localItems,
gameObjects: charState.gameObjects,
constants: localConstants,
recipes: localRecipes,
units: charState.units,
gameTime: this.innerState.gameTime,
});
if (intent) {
const updated = {
c: charId,
i: instanceId as InstanceName,
intent: intent,
unitId: charId,
};
this.setIntent(updated);
}
} else {
// because we run on a timer, we may not have units in our local state
// if we're not fighting in the arena, when this is the case, avoid running onTicks
if (isEmptyObject(charState.units)) continue;
const char = charState.units[charId] as ClientSidePlayer;
const intent = this.onTick({
inArena: true,
instanceId,
arenaTimeRemaining: this.innerState.arenaDurations[instanceId],
player: char ? Object.assign(getHandlers(char), char) : undefined,
boundary: instance.boundary,
items: localItems,
gameObjects: charState.gameObjects,
constants: localConstants,
recipes: localRecipes,
playersSeekingParty: instance.playersSeekingParty,
units: charState.units,
gameTime: this.innerState.gameTime,
});
if (intent) {
const updated = {
c: charId,
i: instanceId as InstanceName,
intent: intent,
unitId: charId,
};
this.setIntent(updated);
}
}
}
}
};
/**
* Handles events coming from the server.
*/
socketEventHandlers: EventMap = {
boundary: (instance, charId, event) => {
const instanceState = this.initializeInstance(instance, charId);
instanceState.boundary = event;
},
focus: (instance, charId, event) => {
this.updateUnit(instance, charId, event.monsterId, (unit) => {
if (!unit) return;
if (unit.type !== "monster") return;
unit.focus = event.focus;
});
},
hazardDamaged: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.hp = event.hp;
});
},
used: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
unit.inventory[event.item] ||= 1;
unit.inventory[event.item]! -= 1;
});
},
combatSkillIncreased: (instance, charId, event) => {
this.updatePlayer(instance, charId, event.unitId, (unit) => {
unit.combatSkills[event.skill] += event.amount;
});
},
dropped: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
unit.inventory[event.item] ||= 0;
unit.inventory[event.item]! -= event.amount;
});
},
loot: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
if (!("inventory" in unit)) return;
entries(event.items).forEach(([item, amount]) => {
if (!amount) return;
unit.inventory[item] ||= 0;
unit.inventory[item]! += amount;
});
});
},
arena: (instance, charId, event) => {
this.innerState.arenaDurations[instance] = event.duration;
},
beganCasting: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.action = "cast";
unit.actionStart = event.gameTime;
this.innerState.gameTime = event.gameTime;
unit.actionUsing = event.spell;
unit.actionDuration = event.duration;
unit.actionTarget = event.target;
});
},
beganEquippingSpell: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
unit.action = "equipSpell";
unit.actionStart = event.gameTime;
unit.actionDuration = event.duration;
unit.actionTarget = event.spell;
this.innerState.gameTime = event.gameTime;
});
},
beganHarvesting: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
unit.action = "harvest";
unit.actionStart = event.gameTime;
unit.actionDuration = event.duration;
unit.actionTarget = event.objectId;
this.innerState.gameTime = event.gameTime;
});
},
harvested: (instanceName, charId, event) => {
// we don't clear the object, the server should
// send a second event telling us the object is gone.
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
this.clearUnitActions(unit);
});
},
castSpell: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.action = undefined;
unit.actionStart = undefined;
unit.actionDuration = undefined;
unit.actionTarget = undefined;
});
},
stats: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.stats = event.stats;
});
},
lostStatus: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
delete unit.statusEffects[event.effect];
});
},
gainedStatus: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.statusEffects[event.effect] = {
duration: event.duration,
source: event.source,
stacks: event.stacks,
effects: event.effects,
};
});
},
mp: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.mp = event.mp;
});
},
usedWeaponSkill: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
unit.tp = event.tp;
});
},
ate: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
if ("calories" in unit) {
unit.calories = event.calories;
}
if ("inventory" in unit) {
unit.inventory[event.item] = event.remaining;
}
});
},
equipped: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
if (isPlayerUnit(unit)) {
// @ts-ignore
unit.equipment[event.slot] = event.item;
}
unit.inventory[event.item] ||= 1;
unit.inventory[event.item]! -= 1;
if (unit.inventory[event.item] === 0) {
delete unit.inventory[event.item];
}
});
},
unequipped: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
if (!unit) return;
if (unit?.type === "player") {
const un = unit as ClientSidePlayer;
// @ts-ignore
unit.equipment[event.slot] = null;
un.inventory[event.item] ||= 0;
un.inventory[event.item]! += 1;
}
});
},
beganCrafting: (instance, charId, event) => {
this.updateUnit(instance, charId, event.unitId, (unit) => {
unit.action = "craft" as const;
unit.actionDuration = event.duration;
unit.actionStart = event.gameTime;
unit.actionTarget = event.item;
this.innerState.gameTime = event.gameTime;
});
},
finishedCrafting: (instance, charId, event) => {
const charState = this.innerState.instances[instance].characters[charId];
this.updateUnit(instance, charId, event.unitId, (unit) => {
entries(event.items).forEach(([item, amount]) => {
unit.inventory[item] ||= 0;
unit.inventory[item]! += amount!;
});
entries(event.spent).forEach(([item, amount]) => {
unit.inventory[item] ||= amount;
unit.inventory[item]! -= amount!;
});
this.clearUnitActions(unit);
});
},
traded: (instance, charId, event) => {
this.updateUnit(instance, charId, event.actingUnitId, (unit): void => {
entries(event.gave).forEach(([item, amount]) => {
unit.inventory[item] ||= 0;
unit.inventory[item]! -= amount!;
});
entries(event.got).forEach(([item, amount]) => {
unit.inventory[item] ||= 0;
unit.inventory[item]! += amount!;
delete unit.trades.wants[item];
});
});
this.updateUnit(instance, charId, event.targetUnitId, (unit): void => {
entries(event.gave).forEach(([item, amount]) => {
unit.inventory[item] ||= 0;
unit.inventory[item]! += amount!;
delete unit.trades.wants[item];
});
entries(event.got).forEach(([item, amount]) => {
unit.inventory[item] ||= 0;
unit.inventory[item]! -= amount!;
});
});
},
moved: (instance, charId, event) => {
this.updateUnit(instance, charId, event.id, (unit) => {
if (!unit) return;
const { x, y } = event;
unit.position = { x, y };
});
},
connectionEvent: (instanceName, charId, event) => {
const player = event.player;
const instance = this.initializeInstance(instanceName, charId);
// This will only come through if our local version doesn't match the server version
if (event.items) {
localItems = {
...localItems,
...event.items,
};
}
if (event.constants) {
localConstants = {
...localConstants,
...event.constants,
};
}
localRecipes = {
...localRecipes,
...event.recipes,
};
instance.characters[player.id] = {
units: event.units,
gameObjects: event.gameObjects,
};
},
unitAppeared: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
const char = instance.characters[charId];
char.units[event.unit.id] = event.unit;
},
unitDisappeared: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
delete instance.characters[charId].units[event.unitId];
},
seekParty: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
let mapped = false;
instance.playersSeekingParty = instance.playersSeekingParty.map((val) => {
if (val.id === event.playersSeeking.id) {
mapped = true;
return event.playersSeeking;
}
return val;
});
if (!mapped) {
instance.playersSeekingParty.push(event.playersSeeking);
}
},
acceptedPartyInvite: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
this.updatePlayer(instanceName, charId, event.inviteeId, (player) => {
player.partyInvites = [];
});
instance.playersSeekingParty = instance.playersSeekingParty.filter(
(val) => {
return val.id !== event.inviteeId;
}
);
},
updatedPartyInvites: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
if (!isPlayerUnit(unit)) return;
unit.partyInvites = event.invites;
});
},
updatedParty: (instanceName, charId, event) => {
Object.keys(event.party).forEach((memberId) => {
this.updateUnit(instanceName, charId, memberId, (unit) => {
if (!unit) return;
unit.party = event.party;
});
});
},
updatedPartyV2: (instanceName, charId, event) => {
Object.keys(event.party).forEach((memberId) => {
this.updateUnit(instanceName, charId, memberId, (unit) => {
if (!unit) return;
unit.party = event.party;
});
});
this.updateUnit(instanceName, charId, event.playerId, (unit) => {
if (!unit) return;
unit.party = event.party;
});
},
updatedRole: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
unit.role = event.role;
});
},
setIntent: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (innerUnit) => {
if (innerUnit) {
innerUnit.intent = event.intent;
// clear out any pending actions
if (event.intent === null) {
this.clearUnitActions(innerUnit);
}
}
});
},
attacked: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.attacked, (attackedUnit) => {
if (!attackedUnit) return;
attackedUnit.hp = event.hp;
});
this.updateUnit(instanceName, charId, event.attacker, (attackerUnit) => {
if (!attackerUnit) return;
attackerUnit.tp = event.attackerTp;
});
},
despawn: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
delete instance.characters[charId].units[event.unitId];
},
hp: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
unit.hp = event.hp;
});
},
tp: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
unit.tp = event.tp;
});
},
calories: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (unit && "calories" in unit) {
unit.calories = event.calories;
}
});
},
died: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
unit.hp = 0;
});
},
invited: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
const player = instance.characters[charId].units[charId];
if (player && isPlayerUnit(player)) {
if (
!player.partyInvites.find((invite) => invite.id === event.inviter.id)
) {
player.partyInvites.push({
id: event.inviter.id,
name: event.inviter.name,
role: event.inviter.role,
});
}
}
},
updatedTrade: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
unit.trades = event.trades;
});
},
acceptedQuest: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, event.unitId, (unit) => {
unit.quests[event.quest.id] = event.quest;
});
if (charId === event.unitId) {
this.updateNpc(instanceName, charId, event.quest.start_npc, (npc) => {
delete npc.availableQuests[event.quest.id];
});
}
},
abandonedQuest: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, event.unitId, (unit) => {
delete unit.quests[event.questId];
});
},
completedQuest: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, event.unitId, (unit) => {
delete unit.quests[event.questId];
});
},
questAvailable: (instanceName, charId, event) => {
this.updateNpc(instanceName, charId, event.npcId, (npc) => {
npc.availableQuests[event.quest.id] = event.quest;
});
},
questUpdate: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
unit.quests[event.quest.id] = event.quest;
});
},
inventory: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
if (!unit) return;
entries(event.inventory).forEach(([item, amount]) => {
if ((amount || 0) <= 0) {
delete unit.inventory[item];
} else {
unit.inventory[item] = amount;
}
});
});
},
storageEmptied: (instanceName, charId) => {
this.updatePlayer(instanceName, charId, charId, (player) => {
player.storage = {};
});
},
storageCharged: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, charId, (player) => {
if (event.coinsLeft) {
player.storage.copperCoin = event.coinsLeft;
} else {
delete player.storage.copperCoin;
}
});
},
withdrew: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, charId, (player) => {
entries(event.items).forEach(([itemId, amount = 0]) => {
player.storage[itemId] = (player.storage[itemId] || 0) - amount;
player.inventory[itemId] = (player.inventory[itemId] || 0) + amount;
if (itemId in player.storage && (player.storage[itemId] || 0) <= 0) {
delete player.storage[itemId];
}
});
});
},
deposited: (instanceName, charId, event) => {
this.updatePlayer(instanceName, charId, charId, (player) => {
entries(event.items).forEach(([itemId, amount = 0]) => {
player.storage[itemId] = (player.storage[itemId] || 0) + amount;
player.inventory[itemId] = (player.inventory[itemId] || 0) - amount;
if (
itemId in player.inventory &&
(player.inventory[itemId] || 0) <= 0
) {
delete player.inventory[itemId];
}
});
});
},
objectAppeared: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
const char = instance.characters[charId];
char.gameObjects[event.object.id] = event.object;
},
objectDisappeared: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
const char = instance.characters[charId];
delete char.gameObjects[event.objectId];
},
objectUpdated: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
const char = instance.characters[charId];
const gameObject = char.gameObjects[event.objectId];
if (gameObject) {
// Update the game object with the new properties
Object.assign(gameObject, event.properties);
}
},
unequippedSpell: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
const spell = unit.spellbook[0];
if (!spell) return;
unit.spellbook.splice(0, 1);
if (unit.type === "player") {
unit.inventory[spell] ||= 0;
unit.inventory[spell] += 1;
}
});
},
equippedSpell: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, event.unitId, (unit) => {
const { spell } = event;
unit.inventory[spell] ||= 1;
unit.inventory[spell] -= 1;
if (unit.inventory[spell] <= 0) {
delete unit.inventory[spell];
}
unit.spellbook.push(spell);
this.clearUnitActions(unit);
});
},
};
private updateUnit = (
instance: string,
charId: string,
unitId: string,
cb: (unit: ClientSideUnit) => void
) => {
const char = this.innerState.instances[instance].characters[charId];
if (!char) return;
const unit = char.units[unitId];
if (!unit) return;
cb(unit);
};
/**
* Called when the unit completes a cast, or craft, or other action that takes time.
*/
private clearUnitActions = (unit: ClientSideUnit) => {
unit.action = undefined;
unit.actionDuration = undefined;
unit.actionStart = undefined;
unit.actionUsing = undefined;
unit.actionTarget = undefined;
};
private updateNpc = (
instance: string,
charId: string,
npcId: string,
cb: (npc: ClientSideNPC) => void
) => {
this.updateUnit(instance, charId, npcId, (possiblyNpc) => {
if (possiblyNpc.type === "npc") {
cb(possiblyNpc);
}
});
};
private updatePlayer = (
instance: string,
charId: string,
unitId: string,
cb: (player: ClientSidePlayer) => void
) => {
this.updateUnit(instance, charId, unitId, (unit) => {
if (!isPlayerUnit(unit)) return;
cb(unit);
});
};
}