diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts index 41c498d..ccc4880 100644 --- a/src/data/contentHelpers.test.ts +++ b/src/data/contentHelpers.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { lookupTable } from "@/rules/tables"; -import { findRoomTemplateForLookup, findTableByCode } from "./contentHelpers"; +import { + findCreatureByName, + findRoomTemplateForLookup, + findTableByCode, +} from "./contentHelpers"; import { sampleContentPack } from "./sampleContentPack"; function createSequenceRoller(values: number[]) { @@ -46,4 +50,11 @@ describe("level 1 content helpers", () => { expect(roomTemplate.title).toBe("Slate Shrine"); expect(roomTemplate.roomClass).toBe("large"); }); + + it("finds a creature definition by display name", () => { + const creature = findCreatureByName(sampleContentPack, "Guard"); + + expect(creature.id).toBe("creature.level1.guard"); + expect(creature.hp).toBeGreaterThan(0); + }); }); diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts index dec59d9..c4f4dfe 100644 --- a/src/data/contentHelpers.ts +++ b/src/data/contentHelpers.ts @@ -1,4 +1,9 @@ -import type { ContentPack, RoomTemplate, TableDefinition } from "@/types/content"; +import type { + ContentPack, + CreatureDefinition, + RoomTemplate, + TableDefinition, +} from "@/types/content"; import type { TableLookupResult } from "@/rules/tables"; export function findTableByCode(content: ContentPack, code: string): TableDefinition { @@ -36,3 +41,23 @@ export function findRoomTemplateForLookup( return findRoomTemplateById(content, roomReference.id); } + +function normalizeName(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function findCreatureByName( + content: ContentPack, + creatureName: string, +): CreatureDefinition { + const normalizedTarget = normalizeName(creatureName); + const creature = content.creatures.find( + (entry) => normalizeName(entry.name) === normalizedTarget, + ); + + if (!creature) { + throw new Error(`Unknown creature name: ${creatureName}`); + } + + return creature; +} diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index 06e3393..b2cd348 100644 --- a/src/data/sampleContentPack.ts +++ b/src/data/sampleContentPack.ts @@ -167,6 +167,92 @@ const samplePack = { traits: ["level-1", "sample"], mvp: true, }, + { + id: "creature.level1.guard", + name: "Guard", + level: 1, + category: "humanoid", + hp: 4, + attackProfile: { + discipline: 1, + precision: 1, + damage: 1, + }, + defenceProfile: { + armour: 1, + }, + xpReward: 2, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.warrior", + name: "Warrior", + level: 1, + category: "humanoid", + hp: 5, + attackProfile: { + discipline: 2, + precision: 1, + damage: 2, + }, + defenceProfile: { + armour: 1, + }, + xpReward: 3, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.thug", + name: "Thug", + level: 1, + category: "humanoid", + hp: 3, + attackProfile: { + discipline: 0, + precision: 1, + damage: 1, + }, + xpReward: 1, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.work-dog", + name: "Work Dog", + level: 1, + category: "beast", + hp: 3, + attackProfile: { + discipline: 0, + precision: 1, + damage: 1, + }, + xpReward: 1, + sourcePage: 102, + traits: ["level-1", "beast"], + mvp: true, + }, + { + id: "creature.level1.guard-dog", + name: "Guard Dog", + level: 1, + category: "beast", + hp: 4, + attackProfile: { + discipline: 1, + precision: 1, + damage: 1, + }, + xpReward: 2, + sourcePage: 102, + traits: ["level-1", "beast"], + mvp: true, + }, ], roomTemplates: [ { diff --git a/src/rules/combat.test.ts b/src/rules/combat.test.ts new file mode 100644 index 0000000..dc2ba88 --- /dev/null +++ b/src/rules/combat.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { startCombatFromRoom } from "./combat"; +import { createRoomStateFromTemplate } from "./rooms"; + +describe("combat bootstrap", () => { + it("creates a combat state from a resolved guard encounter", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + ); + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1G", + creatureIds: ["a", "b"], + creatureNames: ["Guard", "Warrior"], + resultLabel: "Guard and Warrior", + resolved: true, + }; + + const result = startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:00:00.000Z", + }); + + expect(result.combat.id).toBe("room.level1.guard-room.combat"); + expect(result.combat.actingSide).toBe("player"); + expect(result.combat.player.name).toBe("Aster"); + expect(result.combat.enemies.map((enemy) => enemy.name)).toEqual(["Guard", "Warrior"]); + expect(result.combat.enemies[0]?.armourValue).toBe(1); + expect(result.room.encounter?.rewardPending).toBe(true); + expect(result.logEntries[0]?.text).toContain("Guard and Warrior"); + }); + + it("supports single-creature combat from a crate encounter", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Bryn", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.storage-area", + 1, + "room.level1.normal.storage-area", + ); + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1CE", + creatureIds: ["a"], + creatureNames: ["Giant Rat"], + resultLabel: "Giant Rat Pair", + resolved: true, + }; + + const result = startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:05:00.000Z", + }); + + expect(result.combat.enemies).toHaveLength(1); + expect(result.combat.enemies[0]?.sourceDefinitionId).toBe("creature.level1.giant-rat"); + expect(result.combat.player.hpCurrent).toBe(10); + }); + + it("rejects rooms without combat-ready encounter names", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Cyra", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.small.empty", + 1, + "room.level1.small.empty-space", + ); + room.encounter = { + id: `${room.id}.encounter`, + creatureIds: [], + creatureNames: [], + resultLabel: "No encounter", + resolved: true, + }; + + expect(() => + startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + }), + ).toThrow("combat-ready encounter"); + }); +}); diff --git a/src/rules/combat.ts b/src/rules/combat.ts new file mode 100644 index 0000000..458ab3f --- /dev/null +++ b/src/rules/combat.ts @@ -0,0 +1,124 @@ +import { findCreatureByName } from "@/data/contentHelpers"; +import type { ContentPack, CreatureDefinition } from "@/types/content"; +import type { AdventurerState, CombatState, CombatantState, RoomState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +export type StartCombatOptions = { + content: ContentPack; + adventurer: AdventurerState; + room: RoomState; + at?: string; +}; + +export type StartCombatResult = { + combat: CombatState; + room: RoomState; + logEntries: LogEntry[]; +}; + +function createLogEntry( + id: string, + at: string, + type: LogEntry["type"], + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type, + text, + relatedIds, + }; +} + +function createPlayerCombatant(adventurer: AdventurerState): CombatantState { + return { + id: adventurer.id, + name: adventurer.name, + hpCurrent: adventurer.hp.current, + hpMax: adventurer.hp.max, + shift: adventurer.stats.shift, + discipline: adventurer.stats.discipline, + precision: adventurer.stats.precision, + statuses: [...adventurer.statuses], + traits: ["player"], + }; +} + +function createEnemyCombatant( + room: RoomState, + creature: CreatureDefinition, + index: number, +): CombatantState { + return { + id: `${room.id}.enemy.${index + 1}`, + name: creature.name, + sourceDefinitionId: creature.id, + hpCurrent: creature.hp, + hpMax: creature.hp, + shift: 0, + discipline: creature.attackProfile.discipline, + precision: creature.attackProfile.precision, + armourValue: creature.defenceProfile?.armour, + statuses: [], + traits: creature.traits ?? [], + }; +} + +function requireEncounterNames(room: RoomState) { + const creatureNames = room.encounter?.creatureNames?.filter(Boolean); + + if (!room.encounter?.resolved || !creatureNames || creatureNames.length === 0) { + throw new Error(`Room ${room.id} does not have a combat-ready encounter.`); + } + + return creatureNames; +} + +export function startCombatFromRoom( + options: StartCombatOptions, +): StartCombatResult { + const at = options.at ?? new Date().toISOString(); + const creatureNames = requireEncounterNames(options.room); + const enemies = creatureNames.map((creatureName, index) => + createEnemyCombatant( + options.room, + findCreatureByName(options.content, creatureName), + index, + ), + ); + + const combat: CombatState = { + id: `${options.room.id}.combat`, + round: 1, + actingSide: "player", + player: createPlayerCombatant(options.adventurer), + enemies, + combatLog: [ + createLogEntry( + `${options.room.id}.combat.start`, + at, + "combat", + `Combat begins in ${options.room.id} against ${creatureNames.join(" and ")}.`, + [options.room.id, ...enemies.map((enemy) => enemy.id)], + ), + ], + }; + + const room: RoomState = { + ...options.room, + encounter: options.room.encounter + ? { + ...options.room.encounter, + rewardPending: true, + } + : undefined, + }; + + return { + combat, + room, + logEntries: [...combat.combatLog], + }; +}