Feature: Implement staged backend features #8

Merged
keith merged 17 commits from staging/features into main 2026-03-15 19:06:34 +00:00
2 changed files with 387 additions and 0 deletions
Showing only changes of commit 50873e6989 - Show all commits

View File

@@ -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");
});
});

255
src/rules/combatTurns.ts Normal file
View File

@@ -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,
};
}