programming-game
Version:
The client for programming game, an mmorpg that you interact with entirely through code.
896 lines (876 loc) • 28.3 kB
text/typescript
import { OnTick } from ".";
import {
Equipments,
Items,
items,
UniqueItemDefinition,
UsableItems,
} from "./items";
import { recipes } from "./recipes";
import { Spells, CastIntent } from "./spells";
import {
AbandonQuestIntent,
AcceptPartyInviteIntent,
AcceptQuestIntent,
AttackIntent,
BatchedEvents,
Boundary,
BuyItemsIntent,
ClientSideNPC,
ClientSidePlayer,
ClientSideUnit,
CraftIntent,
DepositIntent,
DropIntent,
EatIntent,
EquipIntent,
EventMap,
GameObject,
InstanceName,
Intent,
IntentType,
Inventory,
InviteToPartyIntent,
LeavePartyIntent,
MoveIntent,
NewTrades,
PlayerEquipment,
PlayersSeekingParty,
RawEvents,
RespawnIntent,
ROLES,
SeekPartyIntent,
SellIntent,
SellItemsIntent,
SetRoleIntent,
SetSpellStonesIntent,
SetTradeIntent,
SummonManaIntent,
Trades,
TurnInQuestIntent,
UnequipIntent,
UniqueItemId,
UseIntent,
WeaponSkillIntent,
WithdrawIntent,
} from "./types";
import { WeaponSkill, WeaponSkillSpecifics } from "./weapon-skills";
export type OnTickCurrentPlayer = ClientSidePlayer &
ReturnType<typeof getHandlers>;
const isPlayerUnit = (unit?: ClientSideUnit): unit is ClientSidePlayer => {
return !!unit && unit.type === "player";
};
const entries = <T extends Record<string, unknown>>(
obj: T
): [keyof T, T[keyof T]][] => {
return Object.entries(obj) as [keyof T, T[keyof T]][];
};
const keys = <T extends Record<string, unknown>>(obj: T): (keyof T)[] => {
return Object.keys(obj);
};
const getHandlers = (player: ClientSidePlayer) => {
return {
setRole: (role: ROLES): SetRoleIntent => {
return { type: IntentType.setRole, role };
},
attack: (target: ClientSideUnit): AttackIntent => {
return { type: IntentType.attack, target: target.id };
},
move: (position: { x: number; y: number }): MoveIntent => {
return { type: IntentType.move, position };
},
respawn: (): RespawnIntent => {
return { type: IntentType.respawn } as RespawnIntent;
},
summonMana: (): SummonManaIntent => {
return { type: IntentType.summonMana };
},
eat: (item: Items): EatIntent => {
return {
type: IntentType.eat,
item: item,
save: (player.inventory[item] || 0) - 1,
};
},
cast: (spell: Spells, target?: ClientSideUnit): CastIntent => {
return { type: IntentType.cast, spell, target: target?.id };
},
sell: (opt: {
items: { [key in Items]?: number };
to: ClientSideUnit;
}): SellItemsIntent => {
return {
type: IntentType.sellItems,
items: entries(opt.items).reduce((acc, [item, amount]) => {
const until = (player.inventory[item] || 0) - (amount || 0);
acc[item] = until;
return acc;
}, {} as { [key in Items]: number }),
to: opt.to.id,
};
},
buy: (opts: {
items: { [key in Items]?: number };
from: ClientSideUnit;
}): BuyItemsIntent => {
return {
type: IntentType.buyItems,
items: entries(opts.items).reduce((acc, [item, amount]) => {
if (!amount) return acc;
const until = amount + (player.inventory[item] || 0);
if (amount) {
acc[item] = until;
}
return acc;
}, {} as { [key in Items]: number }),
from: opts.from.id,
};
},
use: (item: UsableItems, target?: ClientSideUnit): UseIntent => {
return {
type: IntentType.use,
item,
until: (player.inventory[item] || 0) - 1,
target: target?.id,
};
},
seekParty: (): SeekPartyIntent => {
return { type: IntentType.seekParty };
},
inviteToParty: (playerId: string): InviteToPartyIntent => {
return { type: IntentType.inviteToParty, playerId };
},
leaveParty: (): LeavePartyIntent => {
return { type: IntentType.leaveParty };
},
acceptPartyInvite: (playerId: string): AcceptPartyInviteIntent => {
return { type: IntentType.acceptPartyInvite, playerId };
},
equip: (item: Equipments, slot: keyof PlayerEquipment): EquipIntent => {
return { type: IntentType.equip, item, slot };
},
unequip: (slot: keyof PlayerEquipment): UnequipIntent => {
return { type: IntentType.unequip, slot };
},
craft: (
item: keyof typeof recipes,
from: Partial<Record<Items, number>>
): CraftIntent => {
return { type: IntentType.craft, item, from };
},
useWeaponSkill: <T extends WeaponSkill>(
specifics: WeaponSkillSpecifics[T]["client"]
): WeaponSkillIntent<T> => {
if (specifics.skill === "misdirectingShot") {
return {
type: IntentType.weaponSkill,
skill: specifics.skill,
target: specifics.target.id,
options: {
to: specifics.target.id,
},
};
}
return {
type: IntentType.weaponSkill,
skill: specifics.skill,
target: specifics.target.id,
};
},
drop: ({ item, amount }: { item: Items; amount: number }): DropIntent => {
return {
type: IntentType.drop,
item,
until: (player.inventory[item] || 0) - amount,
};
},
setSpellStones: (
item: Items,
stones: Spells[],
name: string
): SetSpellStonesIntent => {
return {
type: IntentType.setSpellStones,
item: item,
stones,
name,
};
},
setTrade: (trades: NewTrades): SetTradeIntent => {
return {
type: IntentType.setTrade,
trades,
};
},
acceptQuest: (npc: ClientSideNPC, questId: string): AcceptQuestIntent => {
return { type: IntentType.acceptQuest, npcId: npc.id, questId };
},
abandonQuest: (questId: string): AbandonQuestIntent => {
return { type: IntentType.abandonQuest, questId };
},
turnInQuest: (npc: ClientSideNPC, questId: string): TurnInQuestIntent => {
return { type: IntentType.turnInQuest, npcId: npc.id, questId };
},
withdraw: (npc: ClientSideNPC, items: Inventory): WithdrawIntent => {
const until: Inventory = {};
entries(items).forEach(([item, amount = 0]) => {
const charAmount = player.storage[item] || 0;
until[item] = charAmount - amount;
});
return {
type: IntentType.withdraw,
npcId: npc.id,
until,
};
},
deposit: (npc: ClientSideNPC, items: Inventory): DepositIntent => {
const until: Inventory = {};
entries(items).forEach(([item, amount = 0]) => {
const charAmount = player.inventory[item] || 0;
until[item] = charAmount - amount;
});
return {
type: IntentType.deposit,
npcId: npc.id,
until,
};
},
};
};
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,
}: {
onTick: OnTick;
onEvent?: OnEvent;
setIntent: SetIntent;
}) {
this.onTick = onTick;
this.onEvent = onEvent;
this.setIntent = setIntent;
this.heartBeatInterval = setInterval(this.runOnTick, 300);
}
private time = 0;
private lastOnTick = Date.now();
private innerState: {
instances: {
[key: string]: {
time: number;
characters: Record<
string,
{
units: Record<string, ClientSideUnit>;
uniqueItems: Record<UniqueItemId, UniqueItemDefinition>;
gameObjects: Record<string, GameObject>;
}
>;
playersSeekingParty: Map<string, PlayersSeekingParty[0]>;
boundary?: Boundary;
};
};
arenaDurations: Record<string, number>;
} = {
instances: {},
arenaDurations: {},
};
private initializeInstance = (instance: string, charId: string) => {
this.innerState.instances[instance] ||= {
time: 0,
characters: {},
playersSeekingParty: new Map(),
};
this.innerState.instances[instance].characters[charId] ||= {
units: {},
uniqueItems: {},
gameObjects: {},
};
return this.innerState.instances[instance];
};
public clearState = () => {
this.innerState = {
instances: {},
arenaDurations: {},
};
this.heartBeatInterval && clearInterval(this.heartBeatInterval);
};
eventsHandler = (events: BatchedEvents): void => {
Object.keys(events).forEach((instanceId) => {
const eventCharMap = events[instanceId];
Object.entries(eventCharMap).forEach(([charId, events]) => {
this.initializeInstance(instanceId, charId);
events.forEach(([eventName, eventPayload]) => {
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();
Object.entries(this.innerState.instances).forEach(
([instanceId, instance]) => {
if (this.innerState.arenaDurations[instanceId]) {
this.innerState.arenaDurations[instanceId] -= elapsed;
}
Object.keys(instance.characters).map((charId) => {
const charState = instance.characters[charId];
Object.values(charState.units).forEach((unit) => {
Object.values(unit.statusEffects).forEach((effect) => {
effect.duration -= elapsed;
});
});
this.time++;
if (
instanceId === "overworld" ||
instanceId.startsWith("instance-")
) {
const char = charState.units[charId] as ClientSidePlayer;
if (!char) return;
const intent = this.onTick({
inArena: false,
player: {
...char,
...getHandlers(char),
},
boundary: instance.boundary,
instanceId,
playersSeekingParty: Array.from(
instance.playersSeekingParty.values()
),
items: {
...charState.uniqueItems,
...items,
},
gameObjects: {
...charState.gameObjects,
},
time: this.time,
units: charState.units,
});
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 onTics
if (!Object.keys(charState.units).length) return;
const char = charState.units[charId] as ClientSidePlayer;
const intent = this.onTick({
inArena: true,
instanceId,
arenaTimeRemaining: this.innerState.arenaDurations[instanceId],
player: char
? {
...char,
...getHandlers(char),
}
: undefined,
boundary: instance.boundary,
items: {
...charState.uniqueItems,
...items,
},
gameObjects: {
...charState.gameObjects,
},
playersSeekingParty: Array.from(
instance.playersSeekingParty.values()
),
time: this.time,
units: charState.units,
});
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;
});
},
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 = Date.now();
unit.actionDuration = event.duration;
unit.actionTarget = event.target;
});
},
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;
});
},
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 = Date.now();
unit.actionTarget = event.item;
});
},
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!;
});
if (event.uniqueItems) {
entries(event.uniqueItems).forEach(([item, def]) => {
if (!item || !def) return;
charState.uniqueItems[item] = def;
});
}
unit.action = undefined;
unit.actionDuration = undefined;
unit.actionStart = undefined;
unit.actionUsing = undefined;
unit.actionTarget = undefined;
});
},
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);
instance.characters[player.id] = {
units: event.units,
uniqueItems: event.uniqueItems,
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;
keys(event.uniqueItems).forEach((uniqueItemId) => {
char.uniqueItems[uniqueItemId] = event.uniqueItems[uniqueItemId];
});
},
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);
instance.playersSeekingParty.set(
event.playersSeeking.id,
event.playersSeeking
);
},
acceptedPartyInvite: (instanceName, charId, event) => {
const instance = this.initializeInstance(instanceName, charId);
instance.playersSeekingParty.delete(event.inviteeId);
},
updatedParty: (instanceName, charId, event) => {
this.updateUnit(instanceName, charId, charId, (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;
}
});
},
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)) {
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);
}
},
};
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);
};
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);
});
};
}