roleplayer
Version:
A library for building table top role playing game worlds, and managing campaigns in those worlds
458 lines (365 loc) • 12.8 kB
text/typescript
import type { CampaignState, Resource, ResourceGeneration } from "../..";
import type { Id } from "../../lib/generate-id";
import { OmitFunctions } from "../../types/remove-functions";
import type { WithRequired } from "../../types/with-required";
import type { ActionDefinition } from "../action/action";
import type { StatusDefinition } from "../action/status";
import type { Party } from "../campaign/party";
import { CharacterEventTypes, type RoleplayerEvent } from "../events/events";
import { ItemType, type EquipmentSlotDefinition, type ItemDefinition } from "../inventory/item";
import type { Alignment, CharacterStatType, Clazz, ElementDefinition, Race } from "../ruleset/ruleset";
import { MovementSpeedResourceTypeName } from "../world/resource";
/**
* @module core/actor
*/
export type Position = {
x: number;
y: number;
z: number;
};
/**
* @module core/actor
*/
export type CharacterClass = {
level: number;
classId: Clazz["id"];
};
/**
* @module core/actor
*/
export type CharacterEquipmentSlot = {
item?: CharacterInventoryItem;
slotId: EquipmentSlotDefinition["id"];
};
export type CharacterInventoryItem = {
id: Id;
definition: ItemDefinition;
};
/**
* @module core/actor
*/
export type Reaction = {
id: Id;
name: string;
type: ReactionEventType;
eventType: RoleplayerEvent["type"];
action: ActionDefinition;
};
/**
* @module core/actor
*/
export enum ReactionEventType {
OnHit = "OnHit",
OnDodge = "OnDodge",
}
/**
* @module core/actor
*/
export type ReactionResource = {
reactionId: Reaction["id"];
targetId: Actor["id"];
};
/**
* @module core/actor
*
* Character attributes represents numerically measurable qualities of a character are persistent over time, for example between battles. Stuff like strength, agility, etc.
*
* For expendable/regenerative attributes, see character resources.
*/
export type CharacterAttribute = {
statId: CharacterStatType["id"];
amount: number;
};
export function isCharacterEvent(
event: RoleplayerEvent
): event is Extract<RoleplayerEvent, { characterId: Actor["id"] }> {
return (event as any).characterId !== undefined;
}
/**
* Actor template type, inherited from Actor, with campaign being a required property and all methods stripped.
*/
export type ActorTemplate = OmitFunctions<Required<Omit<ConstructorParameters<typeof Actor>[0], "campaign">>>;
/**
* @module core/actor
*/
export class Actor {
id!: Id;
party!: Party["id"];
exists!: boolean;
templateCharacterId?: Actor["id"];
characterType!: "Player" | "NPC" | "Monster";
xp!: number;
name!: string;
race!: Race["id"];
description?: string;
alignment!: Alignment;
classes: CharacterClass[] = [];
inventory: CharacterInventoryItem[] = [];
equipment: CharacterEquipmentSlot[] = [];
stats: CharacterAttribute[] = [];
actions: ActionDefinition[] = [];
statuses: StatusDefinition[] = [];
position!: Position;
resources: Resource[] = [];
reactionsRemaining: ReactionResource[] = [];
reactions: Reaction[] = [];
campaign!: CampaignState;
unsubscribe: (() => void) | undefined;
constructor(a: WithRequired<Partial<Actor>, "campaign">) {
Object.assign(this, a);
this.unsubscribe = a.campaign.roleplayer.subscribe(this.reduce.bind(this));
}
resetResources(generation: ResourceGeneration[]) {
this.resources = this.resources.map((resource) => {
const resourceGeneration = generation.find((g) => g.resourceTypeId === resource.resourceTypeId);
if (!resourceGeneration) {
return resource;
}
return {
...resource,
amount: Math.min(resource.max, resource.amount + resourceGeneration.amount),
};
});
}
action(actionDefinition: ActionDefinition) {
return {
rolls: [
{
name: "Hit",
roll: 20,
},
],
};
}
/**
* Get all available actions for the characters, based on equipment, class, spells, etc.
* @returns A list of available actions
*/
getAvailableActions(): ActionDefinition[] {
const consumables = this.inventory
.filter((it) => it.definition.type === ItemType.Consumable)
.flatMap((i) => i.definition.actions);
const equipmentActions = this.equipment
.flatMap((eq) => eq.item)
.filter((i) => i)
.flatMap((i) => i!.definition.actions);
return [...this.actions, ...equipmentActions, ...consumables];
}
getDamageAmplify(elementType: ElementDefinition) {
return 1;
}
getResistance(element: ElementDefinition, damage: number) {
return 0;
}
tryHit(target: Actor) {
return true;
}
reduce(event: RoleplayerEvent) {
if (!("characterId" in event) || event.characterId !== this.id) return;
switch (event.type) {
case CharacterEventTypes.CharacterExperienceChanged: {
this.xp += event.experience;
break;
}
case CharacterEventTypes.CharacterExperienceSet: {
this.xp = event.experience;
break;
}
case CharacterEventTypes.CharacterResourceMaxSet: {
const resource = this.resources.find((r) => r.resourceTypeId === event.resourceTypeId);
if (!resource) {
this.resources.push({
amount: 0,
max: event.max,
min: 0,
resourceTypeId: event.resourceTypeId,
});
break;
}
resource.max = event.max;
break;
}
case CharacterEventTypes.CharacterResourceGain: {
const resource = this.resources.find((r) => r.resourceTypeId === event.resourceTypeId);
if (!resource) {
throw new Error(`Cannot find character resource type ${event.resourceTypeId}`);
}
resource.amount += event.amount;
break;
}
case CharacterEventTypes.CharacterResourceLoss: {
const resource = this.resources.find((r) => r.resourceTypeId === event.resourceTypeId);
if (!resource) {
throw new Error(`Cannot find character resource type ${event.resourceTypeId}`);
}
resource.amount -= event.amount;
break;
}
case CharacterEventTypes.CharacterNameSet: {
this.name = event.name;
break;
}
case CharacterEventTypes.CharacterStatChange: {
const stat = this.stats.find((s) => s.statId === event.statId);
if (!stat) {
this.stats.push({ statId: event.statId, amount: event.amount });
break;
}
stat.amount = event.amount;
break;
}
case CharacterEventTypes.CharacterPositionSet: {
if (!event.targetPosition) {
throw new Error("Target position not defined for CharacterPositionSet");
}
this.position = event.targetPosition;
break;
}
case CharacterEventTypes.CharacterAttackAttackerHit: {
break;
}
case CharacterEventTypes.CharacterAttackAttackerMiss: {
break;
}
case CharacterEventTypes.CharacterAttackDefenderHit: {
break;
}
case CharacterEventTypes.CharacterAttackDefenderDodge: {
const availableReactions = this.reactions.filter((r) => r.eventType === "CharacterAttackDefenderDodge");
const reactionResources = availableReactions.map((r) => ({
reactionId: r.id,
targetId: event.attackerId,
}));
this.reactionsRemaining.push(...reactionResources);
break;
}
case CharacterEventTypes.CharacterAttackDefenderParry: {
break;
}
case CharacterEventTypes.CharacterClassReset: {
this.classes = [];
break;
}
case CharacterEventTypes.CharacterClassLevelGain: {
const characterClassLevels = this.classes.length;
const characterLevel = 0; // TODO: Fix this, should be calculated from experience
if (characterClassLevels >= characterLevel) {
throw new Error("Cannot add class levels to character, character level not high enough");
}
const clazz = this.campaign.classes.find((c) => c.id === event.classId);
if (!clazz) {
throw new Error("Class not found");
}
const characterClass = this.classes.find((c) => c.classId === event.classId);
if (!characterClass) {
this.classes.push({ classId: event.classId, level: 1 });
break;
}
characterClass.level = characterClass.level + 1;
break;
}
case CharacterEventTypes.CharacterStatusGain: {
const status = this.campaign.statuses.find((s) => s.id === event.statusId);
if (!status) {
throw new Error(`Could not find status with id ${event.statusId} for CharacterStatusGain`);
}
this.statuses.push(status);
break;
}
case CharacterEventTypes.CharacterInventoryItemLoss: {
// Remove equipped item if it was equipped
this.equipment = this.equipment.map((eq) => {
const equipped = eq.item!.id === event.characterInventoryItemId;
if (equipped) {
eq.item = undefined;
}
return eq;
});
// Remove from inventory
this.inventory = this.inventory.filter((eq) => eq.id === event.characterInventoryItemId);
break;
}
case CharacterEventTypes.CharacterInventoryItemGain: {
const item = this.campaign.itemTemplates.find((eq) => eq.id === event.itemDefinitionId);
if (!item) {
throw new Error(`Could not find item with id ${event.itemDefinitionId} for CharacterGainItem`);
}
this.inventory.push({ id: event.itemInstanceId, definition: item });
break;
}
case CharacterEventTypes.CharacterEquipmentSlotGain: {
const characterSlot = this.campaign.ruleset
.getCharacterEquipmentSlots()
.find((slot) => slot.id === event.equipmentSlotId);
if (!characterSlot) {
throw new Error("Cannot find slot");
}
const existing = this.equipment.find((eq) => eq.slotId === event.equipmentSlotId);
if (existing) {
throw new Error("Character already have equipment slot");
}
this.equipment.push({ slotId: characterSlot.id, item: undefined });
break;
}
case CharacterEventTypes.CharacterInventoryItemEquip: {
const characterHasItem = this.inventory.find((eq) => eq.id === event.itemId);
if (!characterHasItem) {
throw new Error(`Could not find item on character`);
}
const slot = this.equipment.find((e) => e.slotId === event.equipmentSlotId);
if (!slot) {
throw new Error(`Could not find slot on event`);
}
if (slot.item) {
throw new Error("Slot already has item");
}
slot.item = characterHasItem;
break;
}
case CharacterEventTypes.CharacterInventoryItemUnEquip: {
const slot = this.equipment.find((e) => e.slotId === event.equipmentSlotId);
if (!slot) {
throw new Error(`Could not find slot on event`);
}
slot.item = undefined;
break;
}
case CharacterEventTypes.CharacterMovement: {
const resourceType = this.campaign.ruleset
.getCharacterResourceTypes()
.find((r) => r.name === MovementSpeedResourceTypeName);
if (!resourceType) {
throw new Error("Movement resource not defined in world");
}
const characterMovementResource = this.resources.find((r) => r.resourceTypeId === resourceType.id);
if (!characterMovementResource) {
throw new Error("Character does not have a defined movement speed resource");
}
if (!event.targetPosition) {
throw new Error("Target position not defined for CharacterMovement");
}
const distanceX = this.position.x + event.targetPosition.x;
const distanceY = this.position.y + event.targetPosition.y;
const distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2));
if (distance > characterMovementResource.amount) {
throw new Error("Movement exceeds remaining speed for CharacterMovement");
}
characterMovementResource.amount -= distance;
this.position.x = event.targetPosition.x;
this.position.y = event.targetPosition.y;
break;
}
case CharacterEventTypes.CharacterActionGain: {
const action = this.campaign.actions.find((a) => a.id === event.actionId);
if (!action) {
throw new Error(`Unknown action ${event.actionId}`);
}
this.actions.push(action);
return;
}
}
}
dispose() {
this.unsubscribe?.();
}
}