roleplayer
Version:
A library for building table top role playing game worlds, and managing campaigns in those worlds
185 lines (164 loc) • 5.66 kB
text/typescript
import {
Actor,
CampaignState,
CharacterEventTypes,
generateId,
isBattleEvent,
SystemEventType,
type CampaignEvent,
type RoleplayerEvent,
type Ruleset,
} from "..";
import Observable from "../lib/events/observable";
import type { Logger } from "../lib/logging/logger";
import type { WithRequired } from "../types/with-required";
import type { ActionDispatch } from "./actions";
type RoleplayerCampaignParameters = Omit<ConstructorParameters<typeof CampaignState>[0], "roleplayer" | "ruleset">;
/**
* The core of this library contains a few systems that are common for most or all roleplaying games.
* It is designed to be extended and customized to fit the needs of a roleplaying game.
*
* - Action and effects system
* - Battle system
* - NPC actions
* - Resource generation system
* - Round action tracking
* - Character system
* - Resource system
* - Inventory system
* - Stats system
* - Level progression system
* - World building system
* - NPC system
* - Relationship system
* - Dice rolling system
* - Campaign system
* - Factions/relationships system
* - Quest system
* - Exploration system
*
* It is all backed up by a rich event system where subsystems can subscribe and publish events.
*
*/
export class Roleplayer extends Observable<RoleplayerEvent> {
ruleset!: Ruleset;
events: RoleplayerEvent[] = [];
campaign: CampaignState;
logger?: Logger;
constructor(
config: WithRequired<Partial<Roleplayer>, "ruleset">,
initialCampaignConfig: RoleplayerCampaignParameters
) {
super();
Object.assign(this, config);
this.subscribe(this.reduce.bind(this));
this.campaign = new CampaignState({
...initialCampaignConfig,
roleplayer: this,
ruleset: config.ruleset,
});
}
setLogger(logger: Logger) {
this.logger = logger;
}
nextSerialNumber() {
const sortedEvents = this.events.toSorted((a, b) => a.serialNumber - b.serialNumber);
const lastSerialNumber = sortedEvents[sortedEvents.length - 1]?.serialNumber ?? 0;
return lastSerialNumber + 1;
}
dispatchAction<T>(action: ActionDispatch<T>) {
return action(this.dispatchEvents.bind(this), () => this);
}
dispatchEvents(...events: CampaignEvent[]) {
for (const event of events) {
this.dispatch(event);
}
}
dispatch(event: CampaignEvent) {
const eventSerialNumber = this.nextSerialNumber();
const currentRoundId =
event.type === SystemEventType.RoundStarted ? event.roundId : this.campaign.getCurrentRound().id;
let roleplayerEvent: RoleplayerEvent;
// TODO: Extract this to some middleware?
if (isBattleEvent(event)) {
roleplayerEvent = {
...event,
id: generateId(),
battleId: event.battleId,
roundId: currentRoundId,
serialNumber: eventSerialNumber,
};
} else {
const currentBattleId = this.campaign.getCurrentBattle()?.id;
roleplayerEvent = {
...event,
id: generateId(),
battleId: currentBattleId,
roundId: currentRoundId,
serialNumber: eventSerialNumber,
};
}
this.events.push(roleplayerEvent);
this.notify(roleplayerEvent);
}
reduce(event: RoleplayerEvent) {
switch (event.type) {
case SystemEventType.CampaignStarted: {
if (this.campaign.started) {
throw new Error("Campaign already started");
}
this.campaign.started = true;
break;
}
case SystemEventType.RoundStarted: {
this.campaign.rounds.push({
id: event.roundId,
roundNumber: event.serialNumber,
});
for (const character of this.campaign.characters) {
const characterResourceGeneration = this.ruleset.characterResourceGeneration(character);
character.resetResources(characterResourceGeneration);
}
break;
}
case CharacterEventTypes.CharacterSpawned: {
const templateCharacter = this.campaign.actorTemplates.find((c) => c.id === event.templateCharacterId);
const alreadySpawnedTemplateCharacters = this.campaign.characters.filter(
(c) => c.templateCharacterId === event.templateCharacterId
);
if (!templateCharacter) {
this.campaign.characters.push(new Actor({ id: event.characterId, campaign: this.campaign }));
} else {
this.campaign.characters.push(
new Actor({
...structuredClone(templateCharacter),
campaign: this.campaign,
id: event.characterId,
templateCharacterId: event.templateCharacterId,
name: `${templateCharacter.name} #${alreadySpawnedTemplateCharacters.length}`,
})
);
}
break;
}
case CharacterEventTypes.CharacterDespawn: {
const characterIndex = this.campaign.characters.findIndex((c) => c.id === event.characterId);
if (characterIndex === -1) throw new Error(`Could not find character with id: ${event.characterId}`);
const [removedCharacter] = this.campaign.characters.splice(characterIndex, 1);
for (const battle of this.campaign.battles) {
battle.actors = battle.actors.filter((actor) => actor.id !== event.characterId);
}
removedCharacter?.dispose();
break;
}
default:
this.logger?.warn(`Unknown event type ${event.type}`);
}
}
debugEvent(event: RoleplayerEvent) {
// this.logger.table(event);
}
debugEventProcessing(events: RoleplayerEvent[]) {
// this.logger.log(this.campaign.characters);
}
}