roleplayer
Version:
A library for building table top role playing game worlds, and managing campaigns in those worlds
466 lines (406 loc) • 13 kB
text/typescript
import {
Alignment,
CharacterEventTypes,
HealthResourceTypeName,
Rarity,
SystemEventType,
type ResourceDefinition,
} from "../..";
import { DnDRuleset } from "../../data/rulesets/dnd-5th";
import { generateId } from "../../lib/generate-id";
import { TargetType, type ActionDefinition } from "../action/action";
import {
characterBattleEnter,
createCharacterWithDefaults,
nextRound,
spawnCharacterFromTemplate,
startBattle,
startCampaign,
} from "../actions";
import type { CampaignEvent } from "../events/events";
import { ItemEquipmentType, ItemSlot, ItemType, type ItemDefinition } from "../inventory/item";
import { Roleplayer } from "../roleplayer";
import { Actor } from "./character";
const ruleset = new DnDRuleset((str) => {
const [, staticValue = "0"] = str.split("+");
return 2 + +staticValue;
});
describe("Character", () => {
describe("Create character events", () => {
it("should handle create character events", () => {
const characterA = generateId();
const characterB = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.dispatchAction(startCampaign());
roleplayer.dispatchAction(createCharacterWithDefaults(characterA, "Character A"));
roleplayer.dispatchAction(createCharacterWithDefaults(characterB, "Character B"));
const data = roleplayer.campaign;
expect(data.characters.length).toBe(2);
});
it("should apply maximum health change events", () => {
const characterId = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.dispatchAction(nextRound());
roleplayer.dispatchAction(createCharacterWithDefaults(characterId, "Character"));
const resourceType: ResourceDefinition = {
id: generateId(),
name: HealthResourceTypeName,
defaultMax: 20,
};
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterResourceMaxSet,
max: 12,
characterId: characterId,
resourceTypeId: resourceType.id,
},
];
roleplayer.dispatchEvents(...events);
const data = roleplayer.campaign;
const characterFromEvents = data.characters.find((c) => c.id === characterId);
expect(characterFromEvents!.resources.find((r) => r.resourceTypeId === resourceType.id)?.max).toBe(12);
});
it("should apply movement events", () => {
const characterId = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.dispatchAction(nextRound());
roleplayer.dispatchAction(createCharacterWithDefaults(characterId, "Character"));
const movementSpeedResource = ruleset.getCharacterResourceTypes().find((r) => r.name === "Movement speed");
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterResourceGain,
resourceTypeId: movementSpeedResource!.id,
amount: 35,
characterId: characterId,
},
{
type: CharacterEventTypes.CharacterPositionSet,
targetPosition: {
x: 10,
y: 0,
z: 0,
},
characterId: characterId,
},
{
type: CharacterEventTypes.CharacterMovement,
targetPosition: {
x: 10,
y: 20,
z: 0,
},
characterId: characterId,
},
];
roleplayer.dispatchEvents(...events);
const data = roleplayer.campaign;
const characterFromEvents = data.characters.find((c) => c.id === characterId);
expect(characterFromEvents!.position.x).toBe(10);
expect(characterFromEvents!.position.y).toBe(20);
});
it("should reject movement event when movement exceeds remaining movement", () => {
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
const characterId = generateId();
roleplayer.dispatchAction(startCampaign());
roleplayer.dispatchAction(createCharacterWithDefaults(characterId, "Character"));
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterResourceGain,
amount: 35,
characterId: characterId,
resourceTypeId: "00000000-0000-0000-0000-000000001000",
},
{
type: CharacterEventTypes.CharacterPositionSet,
targetPosition: {
x: 10,
y: 0,
z: 0,
},
characterId: characterId,
},
{
type: SystemEventType.RoundStarted,
roundId: generateId(),
},
{
type: CharacterEventTypes.CharacterMovement,
targetPosition: {
x: 10,
y: 50,
z: 0,
},
characterId: characterId,
},
];
expect(() => roleplayer.dispatchEvents(...events)).toThrow();
});
it("should apply temporary resource change events", () => {
const characterId = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.dispatchAction(nextRound());
roleplayer.dispatchAction(createCharacterWithDefaults(characterId, "Character"));
const testResourceType: ResourceDefinition = {
id: generateId(),
name: "Resource",
defaultMax: 20,
};
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterResourceMaxSet,
max: 14,
characterId: characterId,
resourceTypeId: testResourceType.id,
},
{
type: CharacterEventTypes.CharacterResourceGain,
amount: 12,
characterId: characterId,
resourceTypeId: testResourceType.id,
},
{
type: CharacterEventTypes.CharacterResourceLoss,
amount: 8,
characterId: characterId,
resourceTypeId: testResourceType.id,
},
];
roleplayer.dispatchEvents(...events);
const data = roleplayer.campaign;
const characterFromEvents = data.characters.find((c) => c.id === characterId);
expect(characterFromEvents!.resources.find((r) => r.resourceTypeId === testResourceType.id)?.max).toBe(14);
expect(characterFromEvents!.resources.find((r) => r.resourceTypeId === testResourceType.id)?.amount).toBe(4);
});
it("should apply item gain events", () => {
const characterId = generateId();
const itemId = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{
id: generateId(),
itemTemplates: [
{
id: itemId,
rarity: Rarity.Common,
weightUnits: 1,
actions: [],
name: "Item",
stats: [],
occupiesSlots: [ItemSlot.Inventory],
type: ItemType.Equipment,
description: "",
equipmentType: ItemEquipmentType.OneHandWeapon,
},
],
}
);
roleplayer.dispatchAction(nextRound());
roleplayer.dispatchAction(createCharacterWithDefaults(characterId, "Character"));
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterSpawned,
characterId: characterId,
},
{
type: CharacterEventTypes.CharacterInventoryItemGain,
itemInstanceId: generateId(),
characterId: characterId,
itemDefinitionId: itemId,
},
];
roleplayer.dispatchEvents(...events);
const data = roleplayer.campaign;
const characterFromEvents = data.characters.find((c) => c.id === characterId);
expect(characterFromEvents!.inventory.length).toBe(1);
});
it("should apply reject character events if character doesn't exist", () => {
const characterId = generateId();
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.dispatchAction(nextRound());
const resourceType: ResourceDefinition = {
id: generateId(),
name: "Resource",
defaultMax: 20,
};
const events: CampaignEvent[] = [
{
type: CharacterEventTypes.CharacterResourceMaxSet,
max: 14,
characterId: characterId,
resourceTypeId: resourceType.id,
},
];
roleplayer.dispatchEvents(...events);
try {
roleplayer.campaign;
} catch (e) {
// Success
}
});
it("should handle unknown events gracefully", () => {
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
const events: CampaignEvent[] = [
{
type: SystemEventType.Unknown,
},
];
roleplayer.dispatchAction(nextRound());
roleplayer.dispatchEvents(...events);
const data = roleplayer.campaign;
expect(data).toBeDefined();
});
it("should set correct resource types from template", () => {
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId() }
);
roleplayer.campaign.actorTemplates.push({
id: "small-monster",
name: "Small monster",
actions: [],
characterType: "Monster",
resources: [
{
resourceTypeId: "actionPoints",
amount: 2,
max: 2,
min: 0,
},
],
statuses: [],
classes: [],
party: "",
exists: false,
templateCharacterId: "",
xp: 0,
race: "",
description: "",
alignment: Alignment.ChaoticEvil,
inventory: [],
equipment: [],
stats: [],
position: {
x: 0,
y: 0,
z: 0,
},
reactionsRemaining: [],
reactions: [],
});
roleplayer.dispatchAction(startCampaign());
roleplayer.dispatchAction(startBattle());
const characterId = roleplayer.dispatchAction(spawnCharacterFromTemplate("small-monster"));
const currentBattle = roleplayer.campaign.getCurrentBattle();
if (!currentBattle) throw new Error("No current battle");
roleplayer.dispatchAction(characterBattleEnter(characterId, currentBattle?.id));
const character = currentBattle?.actors.find((actor) => actor.id === characterId);
const resource = character?.resources.find((resource) => resource.resourceTypeId === "actionPoints");
expect(resource?.amount).toBe(2);
expect(character?.name).toBe("Small monster #0");
});
});
describe("Character actions", () => {
it("should return a list of possible actions based on the character class abilities, spells and inventory", () => {
const sword: ItemDefinition = {
id: generateId(),
rarity: Rarity.Common,
weightUnits: 1,
stats: [],
description: "",
equipmentType: ItemEquipmentType.OneHandWeapon,
actions: [
{
id: generateId(),
appliesEffects: [],
eligibleTargets: [TargetType.Hostile],
name: "Slash",
rangeDistanceUnits: 5,
requiresResources: [],
description: "",
},
],
name: "Sword",
occupiesSlots: [ItemSlot.MainHand],
type: ItemType.Equipment,
};
const healingWord: ActionDefinition = {
id: generateId(),
name: "Healing Word (Level 1)",
appliesEffects: [],
eligibleTargets: [TargetType.Friendly],
rangeDistanceUnits: 30,
requiresResources: [],
description: "",
};
const firebolt: ActionDefinition = {
id: generateId(),
appliesEffects: [],
eligibleTargets: [TargetType.Hostile],
name: "Firebolt",
rangeDistanceUnits: 35,
requiresResources: [
{
resourceTypeId: generateId(),
amount: 1,
},
],
description: "",
};
const roleplayer = new Roleplayer(
{
ruleset,
},
{ id: generateId(), actions: [healingWord, firebolt], itemTemplates: [sword] }
);
const char = new Actor({ campaign: roleplayer.campaign });
char.equipment = [
{
slotId: generateId(),
item: { id: generateId(), definition: sword },
},
];
char.actions = [healingWord, firebolt];
const actions = char.getAvailableActions();
expect(actions.length).toBe(3);
});
});
});