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