Right-click or long-press the d20 button (per-combatant or Roll All) to open a context menu with Advantage and Disadvantage options. Normal left-click behavior is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
238 lines
6.2 KiB
TypeScript
238 lines
6.2 KiB
TypeScript
import {
|
||
type Creature,
|
||
combatantId,
|
||
createEncounter,
|
||
creatureId,
|
||
isDomainError,
|
||
} from "@initiative/domain";
|
||
import { describe, expect, it } from "vitest";
|
||
import { rollAllInitiativeUseCase } from "../roll-all-initiative-use-case.js";
|
||
import {
|
||
expectError,
|
||
expectSuccess,
|
||
requireSaved,
|
||
stubEncounterStore,
|
||
} from "./helpers.js";
|
||
|
||
const CREATURE_A = creatureId("creature-a");
|
||
const CREATURE_B = creatureId("creature-b");
|
||
|
||
function makeCreature(id: string, dex = 14): Creature {
|
||
return {
|
||
id: creatureId(id),
|
||
name: `Creature ${id}`,
|
||
source: "mm",
|
||
sourceDisplayName: "Monster Manual",
|
||
size: "Medium",
|
||
type: "humanoid",
|
||
alignment: "neutral",
|
||
ac: 12,
|
||
hp: { average: 10, formula: "2d8+2" },
|
||
speed: "30 ft.",
|
||
abilities: { str: 10, dex, con: 10, int: 10, wis: 10, cha: 10 },
|
||
cr: "1",
|
||
initiativeProficiency: 0,
|
||
proficiencyBonus: 2,
|
||
passive: 10,
|
||
};
|
||
}
|
||
|
||
function encounterWithCombatants(
|
||
combatants: Array<{
|
||
name: string;
|
||
creatureId?: string;
|
||
initiative?: number;
|
||
}>,
|
||
) {
|
||
const result = createEncounter(
|
||
combatants.map((c) => ({
|
||
id: combatantId(c.name),
|
||
name: c.name,
|
||
creatureId: c.creatureId ? creatureId(c.creatureId) : undefined,
|
||
initiative: c.initiative,
|
||
})),
|
||
);
|
||
if (isDomainError(result)) throw new Error("Setup failed");
|
||
return result;
|
||
}
|
||
|
||
describe("rollAllInitiativeUseCase", () => {
|
||
it("skips combatants without creatureId", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "Fighter" },
|
||
{ name: "Goblin", creatureId: "creature-a" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
const creature = makeCreature("creature-a");
|
||
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => 10,
|
||
(id) => (id === CREATURE_A ? creature : undefined),
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(result.events.length).toBeGreaterThan(0);
|
||
const saved = requireSaved(store.saved);
|
||
const fighter = saved.combatants.find((c) => c.name === "Fighter");
|
||
const goblin = saved.combatants.find((c) => c.name === "Goblin");
|
||
expect(fighter?.initiative).toBeUndefined();
|
||
expect(goblin?.initiative).toBeDefined();
|
||
});
|
||
|
||
it("skips combatants that already have initiative", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "Goblin", creatureId: "creature-a", initiative: 15 },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => 10,
|
||
() => makeCreature("creature-a"),
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(result.events).toHaveLength(0);
|
||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
|
||
});
|
||
|
||
it("counts skippedNoSource when creature lookup returns undefined", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "Unknown", creatureId: "missing" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => 10,
|
||
() => undefined,
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(result.skippedNoSource).toBe(1);
|
||
expect(result.events).toHaveLength(0);
|
||
});
|
||
|
||
it("accumulates events from multiple setInitiative calls", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "A", creatureId: "creature-a" },
|
||
{ name: "B", creatureId: "creature-b" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
const creatureA = makeCreature("creature-a");
|
||
const creatureB = makeCreature("creature-b");
|
||
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => 10,
|
||
(id) => {
|
||
if (id === CREATURE_A) return creatureA;
|
||
if (id === CREATURE_B) return creatureB;
|
||
return undefined;
|
||
},
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(result.events).toHaveLength(2);
|
||
});
|
||
|
||
it("returns early with domain error on invalid dice roll", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "A", creatureId: "creature-a" },
|
||
{ name: "B", creatureId: "creature-b" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
|
||
// rollDice returns 0 (invalid — must be 1–20), triggers early return
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => 0,
|
||
(id) => {
|
||
if (id === CREATURE_A) return makeCreature("creature-a");
|
||
if (id === CREATURE_B) return makeCreature("creature-b");
|
||
return undefined;
|
||
},
|
||
);
|
||
|
||
expectError(result);
|
||
expect(result.code).toBe("invalid-dice-roll");
|
||
// Store should NOT have been saved since the loop aborted
|
||
expect(store.saved).toBeNull();
|
||
});
|
||
|
||
it("uses higher roll with advantage", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "A", creatureId: "creature-a" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
const creature = makeCreature("creature-a");
|
||
|
||
// Alternating rolls: 5, 15 → advantage picks 15
|
||
// Dex 14 → modifier +2, so 15 + 2 = 17
|
||
let call = 0;
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => (++call % 2 === 1 ? 5 : 15),
|
||
(id) => (id === CREATURE_A ? creature : undefined),
|
||
"advantage",
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||
});
|
||
|
||
it("uses lower roll with disadvantage", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "A", creatureId: "creature-a" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
const creature = makeCreature("creature-a");
|
||
|
||
// Alternating rolls: 15, 5 → disadvantage picks 5
|
||
// Dex 14 → modifier +2, so 5 + 2 = 7
|
||
let call = 0;
|
||
const result = rollAllInitiativeUseCase(
|
||
store,
|
||
() => (++call % 2 === 1 ? 15 : 5),
|
||
(id) => (id === CREATURE_A ? creature : undefined),
|
||
"disadvantage",
|
||
);
|
||
|
||
expectSuccess(result);
|
||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||
});
|
||
|
||
it("saves encounter once at the end", () => {
|
||
const enc = encounterWithCombatants([
|
||
{ name: "A", creatureId: "creature-a" },
|
||
{ name: "B", creatureId: "creature-b" },
|
||
]);
|
||
const store = stubEncounterStore(enc);
|
||
const creatureA = makeCreature("creature-a");
|
||
const creatureB = makeCreature("creature-b");
|
||
|
||
let saveCount = 0;
|
||
const originalSave = store.save.bind(store);
|
||
store.save = (e) => {
|
||
saveCount++;
|
||
originalSave(e);
|
||
};
|
||
|
||
rollAllInitiativeUseCase(
|
||
store,
|
||
() => 10,
|
||
(id) => {
|
||
if (id === CREATURE_A) return creatureA;
|
||
if (id === CREATURE_B) return creatureB;
|
||
return undefined;
|
||
},
|
||
);
|
||
|
||
expect(saveCount).toBe(1);
|
||
const saved = requireSaved(store.saved);
|
||
expect(saved.combatants[0].initiative).toBeDefined();
|
||
expect(saved.combatants[1].initiative).toBeDefined();
|
||
});
|
||
});
|