Implement the 025-display-initiative feature that adds initiative modifier and passive initiative display to creature stat blocks, calculated as DEX modifier + (proficiency multiplier × proficiency bonus) from bestiary data, shown in MM 2024 format on the AC line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 11:27:46 +01:00
parent c6349928eb
commit 5b0bac880d
14 changed files with 650 additions and 1 deletions

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
calculateInitiative,
formatInitiativeModifier,
} from "../initiative.js";
describe("calculateInitiative", () => {
it("returns positive modifier for creature with expertise (Aboleth: DEX 9, CR 10, proficiency 2)", () => {
const result = calculateInitiative({
dexScore: 9,
cr: "10",
initiativeProficiency: 2,
});
// DEX mod = floor((9-10)/2) = -1, PB for CR 10 = 4, -1 + 2*4 = +7
expect(result.modifier).toBe(7);
expect(result.passive).toBe(17);
});
it("returns negative modifier for low DEX with no proficiency", () => {
const result = calculateInitiative({
dexScore: 8,
cr: "1",
initiativeProficiency: 0,
});
// DEX mod = floor((8-10)/2) = -1, 0 * PB = 0, -1 + 0 = -1
expect(result.modifier).toBe(-1);
expect(result.passive).toBe(9);
});
it("returns zero modifier for DEX 10 with no proficiency", () => {
const result = calculateInitiative({
dexScore: 10,
cr: "1",
initiativeProficiency: 0,
});
expect(result.modifier).toBe(0);
expect(result.passive).toBe(10);
});
it("adds single proficiency bonus (multiplier 1)", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 1,
});
// DEX mod = +2, PB for CR 5 = 3, 2 + 1*3 = 5
expect(result.modifier).toBe(5);
expect(result.passive).toBe(15);
});
it("adds double proficiency bonus (multiplier 2 / expertise)", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 2,
});
// DEX mod = +2, PB for CR 5 = 3, 2 + 2*3 = 8
expect(result.modifier).toBe(8);
expect(result.passive).toBe(18);
});
it("handles no proficiency (multiplier 0) — reduces to raw DEX modifier", () => {
const result = calculateInitiative({
dexScore: 14,
cr: "5",
initiativeProficiency: 0,
});
// DEX mod = +2, 0 * PB = 0, 2 + 0 = 2
expect(result.modifier).toBe(2);
expect(result.passive).toBe(12);
});
it("handles negative result even with proficiency (very low DEX)", () => {
const result = calculateInitiative({
dexScore: 3,
cr: "0",
initiativeProficiency: 1,
});
// DEX mod = floor((3-10)/2) = -4, PB for CR 0 = 2, -4 + 1*2 = -2
expect(result.modifier).toBe(-2);
expect(result.passive).toBe(8);
});
it("handles fractional CR values", () => {
const result = calculateInitiative({
dexScore: 12,
cr: "1/4",
initiativeProficiency: 1,
});
// DEX mod = +1, PB for CR 1/4 = 2, 1 + 1*2 = 3
expect(result.modifier).toBe(3);
expect(result.passive).toBe(13);
});
});
describe("formatInitiativeModifier", () => {
it("formats positive modifier with plus sign", () => {
expect(formatInitiativeModifier(7)).toBe("+7");
});
it("formats negative modifier with U+2212 minus sign", () => {
expect(formatInitiativeModifier(-1)).toBe("\u22121");
});
it("formats zero modifier with plus sign", () => {
expect(formatInitiativeModifier(0)).toBe("+0");
});
});

View File

@@ -56,6 +56,7 @@ export interface Creature {
readonly cha: number;
};
readonly cr: string;
readonly initiativeProficiency: number;
readonly proficiencyBonus: number;
readonly passive: number;
readonly savingThrows?: string;

View File

@@ -47,6 +47,11 @@ export type {
TurnRetreated,
} from "./events.js";
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
export {
calculateInitiative,
formatInitiativeModifier,
type InitiativeResult,
} from "./initiative.js";
export {
type RemoveCombatantSuccess,
removeCombatant,

View File

@@ -0,0 +1,30 @@
import { proficiencyBonus } from "./creature-types.js";
export interface InitiativeResult {
readonly modifier: number;
readonly passive: number;
}
/**
* Calculates initiative modifier and passive initiative from creature stats.
* Returns undefined for combatants without bestiary creature data.
*/
export function calculateInitiative(creature: {
readonly dexScore: number;
readonly cr: string;
readonly initiativeProficiency: number;
}): InitiativeResult {
const dexMod = Math.floor((creature.dexScore - 10) / 2);
const pb = proficiencyBonus(creature.cr);
const modifier = dexMod + (creature.initiativeProficiency ?? 0) * pb;
return { modifier, passive: 10 + modifier };
}
/**
* Formats an initiative modifier with explicit sign.
* Uses U+2212 () for negative values.
*/
export function formatInitiativeModifier(modifier: number): string {
if (modifier >= 0) return `+${modifier}`;
return `\u2212${Math.abs(modifier)}`;
}