From 50873e6989771b070bb7ca41f5faaf47bad5904a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:44:46 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20combat=20turn?= =?UTF-8?q?=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, + }; +} -- 2.49.1