From ec0de5b0b8459b73d8a9eb4cf316b01ce93e850b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:35:13 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8Feature:=20add=20creature=20lookup?= =?UTF-8?q?=20functionality=20and=20combat=20state=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/contentHelpers.test.ts | 13 +++- src/data/contentHelpers.ts | 27 ++++++- src/data/sampleContentPack.ts | 86 ++++++++++++++++++++++ src/rules/combat.test.ts | 111 ++++++++++++++++++++++++++++ src/rules/combat.ts | 124 ++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/rules/combat.test.ts create mode 100644 src/rules/combat.ts 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], + }; +} From 50873e6989771b070bb7ca41f5faaf47bad5904a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:44:46 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20combat=20tu?= =?UTF-8?q?rn=20resolution=20with=20player=20and=20enemy=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/combatTurns.test.ts | 132 ++++++++++++++++++ src/rules/combatTurns.ts | 255 ++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/rules/combatTurns.test.ts create mode 100644 src/rules/combatTurns.ts diff --git a/src/rules/combatTurns.test.ts b/src/rules/combatTurns.test.ts new file mode 100644 index 0000000..fdcb5fe --- /dev/null +++ b/src/rules/combatTurns.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { startCombatFromRoom } from "./combat"; +import { resolveEnemyTurn, resolvePlayerAttack } from "./combatTurns"; +import { createRoomStateFromTemplate } from "./rooms"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +function createGuardCombat() { + 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, + }; + + return { + adventurer, + ...startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:00:00.000Z", + }), + }; +} + +describe("combat turns", () => { + it("lets the player hit an enemy and pass initiative", () => { + const { adventurer, combat } = createGuardCombat(); + const targetId = combat.enemies[0]!.id; + + const result = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: targetId, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T13:01:00.000Z", + }); + + expect(result.combat.actingSide).toBe("enemy"); + expect(result.combat.selectedManoeuvreId).toBe("manoeuvre.exact-strike"); + expect(result.combat.enemies[0]?.hpCurrent).toBeLessThan(result.combat.enemies[0]!.hpMax); + expect(result.logEntries[0]?.text).toContain("deals"); + }); + + it("marks an enemy defeated when the player deals lethal damage", () => { + const { adventurer, combat } = createGuardCombat(); + combat.enemies[0]!.hpCurrent = 1; + + const result = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.guard-break", + targetEnemyId: combat.enemies[0]!.id, + roller: createSequenceRoller([6, 5]), + at: "2026-03-15T13:02:00.000Z", + }); + + expect(result.defeatedEnemyIds).toEqual([combat.enemies[0]!.id]); + expect(result.logEntries.map((entry) => entry.text)).toContain("Guard is defeated."); + }); + + it("lets an enemy attack the player and advances the round", () => { + const { adventurer, combat } = createGuardCombat(); + const afterPlayer = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: combat.enemies[0]!.id, + roller: createSequenceRoller([4, 4]), + at: "2026-03-15T13:03:00.000Z", + }); + + const result = resolveEnemyTurn({ + content: sampleContentPack, + combat: afterPlayer.combat, + adventurer, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T13:04:00.000Z", + }); + + expect(result.combat.round).toBe(2); + expect(result.combat.actingSide).toBe("player"); + expect(result.combat.player.hpCurrent).toBeLessThan(result.combat.player.hpMax); + expect(result.logEntries[0]?.text).toContain("attacks"); + }); + + it("rejects player turns that target a defeated enemy", () => { + const { adventurer, combat } = createGuardCombat(); + combat.enemies[0]!.hpCurrent = 0; + + expect(() => + resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: combat.enemies[0]!.id, + }), + ).toThrow("Unknown or defeated target"); + }); +}); diff --git a/src/rules/combatTurns.ts b/src/rules/combatTurns.ts new file mode 100644 index 0000000..953499c --- /dev/null +++ b/src/rules/combatTurns.ts @@ -0,0 +1,255 @@ +import type { + ArmourDefinition, + ContentPack, + ManoeuvreDefinition, + WeaponDefinition, +} from "@/types/content"; +import type { AdventurerState, CombatState, CombatantState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +import { roll2D6, type DiceRoller } from "./dice"; + +export type ResolvePlayerAttackOptions = { + content: ContentPack; + combat: CombatState; + adventurer: AdventurerState; + manoeuvreId: string; + targetEnemyId: string; + roller?: DiceRoller; + at?: string; +}; + +export type ResolveEnemyTurnOptions = { + content: ContentPack; + combat: CombatState; + adventurer: AdventurerState; + roller?: DiceRoller; + at?: string; +}; + +export type CombatTurnResult = { + combat: CombatState; + logEntries: LogEntry[]; + defeatedEnemyIds: string[]; + combatEnded: boolean; +}; + +const BASE_TARGET_NUMBER = 7; + +function requireWeapon(content: ContentPack, weaponId: string): WeaponDefinition { + const weapon = content.weapons.find((entry) => entry.id === weaponId); + + if (!weapon) { + throw new Error(`Unknown weapon id: ${weaponId}`); + } + + return weapon; +} + +function requireManoeuvre(content: ContentPack, manoeuvreId: string): ManoeuvreDefinition { + const manoeuvre = content.manoeuvres.find((entry) => entry.id === manoeuvreId); + + if (!manoeuvre) { + throw new Error(`Unknown manoeuvre id: ${manoeuvreId}`); + } + + return manoeuvre; +} + +function findArmour(content: ContentPack, armourId?: string): ArmourDefinition | undefined { + if (!armourId) { + return undefined; + } + + return content.armour.find((entry) => entry.id === armourId); +} + +function cloneCombatant(combatant: CombatantState): CombatantState { + return { + ...combatant, + statuses: [...combatant.statuses], + traits: [...combatant.traits], + }; +} + +function cloneCombat(combat: CombatState): CombatState { + return { + ...combat, + player: cloneCombatant(combat.player), + enemies: combat.enemies.map(cloneCombatant), + combatLog: combat.combatLog.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + }; +} + +function createLogEntry( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "combat", + text, + relatedIds, + }; +} + +function getPlayerArmourValue(content: ContentPack, adventurer: AdventurerState) { + return findArmour(content, adventurer.armourId)?.armourValue ?? 0; +} + +function isAlive(combatant: CombatantState) { + return combatant.hpCurrent > 0; +} + +function getLivingEnemies(combat: CombatState) { + return combat.enemies.filter(isAlive); +} + +export function resolvePlayerAttack( + options: ResolvePlayerAttackOptions, +): CombatTurnResult { + if (options.combat.actingSide !== "player") { + throw new Error("It is not currently the player's turn."); + } + + const combat = cloneCombat(options.combat); + const at = options.at ?? new Date().toISOString(); + const weapon = requireWeapon(options.content, options.adventurer.weaponId); + const manoeuvre = requireManoeuvre(options.content, options.manoeuvreId); + + if (!options.adventurer.manoeuvreIds.includes(manoeuvre.id)) { + throw new Error(`Adventurer cannot use manoeuvre ${manoeuvre.id}.`); + } + + if (!manoeuvre.weaponCategories.includes(weapon.category)) { + throw new Error(`Manoeuvre ${manoeuvre.id} is not compatible with ${weapon.id}.`); + } + + const target = combat.enemies.find((enemy) => enemy.id === options.targetEnemyId); + + if (!target || !isAlive(target)) { + throw new Error(`Unknown or defeated target enemy: ${options.targetEnemyId}`); + } + + const roll = roll2D6(options.roller); + const accuracy = + (roll.total ?? 0) + + combat.player.precision + + (manoeuvre.precisionModifier ?? 0); + const targetNumber = BASE_TARGET_NUMBER + (target.armourValue ?? 0); + const hit = accuracy >= targetNumber; + const rawDamage = hit + ? weapon.baseDamage + + Math.max(0, combat.player.discipline + (manoeuvre.damageModifier ?? 0)) + : 0; + const damage = hit ? Math.max(1, rawDamage) : 0; + + if (hit) { + target.hpCurrent = Math.max(0, target.hpCurrent - damage); + } + + combat.selectedManoeuvreId = manoeuvre.id; + combat.lastRoll = roll; + combat.actingSide = getLivingEnemies(combat).length === 0 ? "player" : "enemy"; + + const logEntries: LogEntry[] = [ + createLogEntry( + `${combat.id}.player.${combat.combatLog.length + 1}`, + at, + hit + ? `${combat.player.name} uses ${manoeuvre.name} on ${target.name}, rolls ${roll.total}, and deals ${damage} damage.` + : `${combat.player.name} uses ${manoeuvre.name} on ${target.name}, rolls ${roll.total}, and misses.`, + [combat.player.id, target.id], + ), + ]; + + const defeatedEnemyIds = !isAlive(target) ? [target.id] : []; + + if (defeatedEnemyIds.length > 0) { + logEntries.push( + createLogEntry( + `${combat.id}.player.${combat.combatLog.length + 2}`, + at, + `${target.name} is defeated.`, + [target.id], + ), + ); + } + + combat.combatLog.push(...logEntries); + + return { + combat, + logEntries, + defeatedEnemyIds, + combatEnded: getLivingEnemies(combat).length === 0, + }; +} + +export function resolveEnemyTurn( + options: ResolveEnemyTurnOptions, +): CombatTurnResult { + if (options.combat.actingSide !== "enemy") { + throw new Error("It is not currently the enemy's turn."); + } + + const combat = cloneCombat(options.combat); + const at = options.at ?? new Date().toISOString(); + const attacker = getLivingEnemies(combat)[0]; + + if (!attacker) { + throw new Error("No living enemies are available to act."); + } + + const roll = roll2D6(options.roller); + const armourValue = getPlayerArmourValue(options.content, options.adventurer); + const accuracy = (roll.total ?? 0) + attacker.precision; + const targetNumber = BASE_TARGET_NUMBER + armourValue; + const hit = accuracy >= targetNumber; + const rawDamage = hit ? Math.max(1, 1 + attacker.discipline) : 0; + + if (hit) { + combat.player.hpCurrent = Math.max(0, combat.player.hpCurrent - rawDamage); + } + + combat.lastRoll = roll; + combat.actingSide = combat.player.hpCurrent > 0 ? "player" : "enemy"; + combat.round += 1; + + const logEntries: LogEntry[] = [ + createLogEntry( + `${combat.id}.enemy.${combat.combatLog.length + 1}`, + at, + hit + ? `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and deals ${rawDamage} damage.` + : `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and misses.`, + [attacker.id, combat.player.id], + ), + ]; + + if (combat.player.hpCurrent === 0) { + logEntries.push( + createLogEntry( + `${combat.id}.enemy.${combat.combatLog.length + 2}`, + at, + `${combat.player.name} is defeated.`, + [combat.player.id], + ), + ); + } + + combat.combatLog.push(...logEntries); + + return { + combat, + logEntries, + defeatedEnemyIds: [], + combatEnded: combat.player.hpCurrent === 0, + }; +}