291 lines
7.8 KiB
TypeScript
291 lines
7.8 KiB
TypeScript
import type {
|
|
ArmourDefinition,
|
|
ContentPack,
|
|
ManoeuvreDefinition,
|
|
WeaponDefinition,
|
|
} from "@/types/content";
|
|
import type { AdventurerState, CombatState, CombatantState } from "@/types/state";
|
|
import type { LogEntry } from "@/types/rules";
|
|
|
|
import {
|
|
INSIGHTFUL_COMBAT_STATUS_ID,
|
|
SLEEPING_STATUS_ID,
|
|
consumeWardReduction,
|
|
consumeStatusValue,
|
|
} from "./magicItems";
|
|
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 insightfulBonus = consumeStatusValue(combat.player.statuses, INSIGHTFUL_COMBAT_STATUS_ID);
|
|
const accuracy =
|
|
(roll.total ?? 0) +
|
|
combat.player.precision +
|
|
insightfulBonus +
|
|
(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 sleptThroughTurn = consumeStatusValue(attacker.statuses, SLEEPING_STATUS_ID) > 0;
|
|
|
|
if (sleptThroughTurn) {
|
|
combat.actingSide = "player";
|
|
combat.round += 1;
|
|
|
|
const logEntries: LogEntry[] = [
|
|
createLogEntry(
|
|
`${combat.id}.enemy.${combat.combatLog.length + 1}`,
|
|
at,
|
|
`${attacker.name} sleeps through the turn.`,
|
|
[attacker.id, combat.player.id],
|
|
),
|
|
];
|
|
|
|
combat.combatLog.push(...logEntries);
|
|
|
|
return {
|
|
combat,
|
|
logEntries,
|
|
defeatedEnemyIds: [],
|
|
combatEnded: false,
|
|
};
|
|
}
|
|
|
|
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;
|
|
const damageReduction = hit ? consumeWardReduction(combat.player.statuses) : 0;
|
|
const damage = hit ? Math.max(0, rawDamage - damageReduction) : 0;
|
|
|
|
if (hit) {
|
|
combat.player.hpCurrent = Math.max(0, combat.player.hpCurrent - damage);
|
|
}
|
|
|
|
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 ${damage} damage${damageReduction > 0 ? ` after resistance reduces it by ${damageReduction}` : ""}.`
|
|
: `${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,
|
|
};
|
|
}
|