Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
packages/domain/src/__tests__/auto-number.test.ts
Normal file
49
packages/domain/src/__tests__/auto-number.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCreatureName } from "../auto-number.js";
|
||||
|
||||
describe("resolveCreatureName", () => {
|
||||
it("returns name as-is when no conflict exists", () => {
|
||||
const result = resolveCreatureName("Goblin", ["Orc", "Dragon"]);
|
||||
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||
});
|
||||
|
||||
it("returns name as-is when existing list is empty", () => {
|
||||
const result = resolveCreatureName("Goblin", []);
|
||||
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||
});
|
||||
|
||||
it("renames existing to 'Name 1' and new to 'Name 2' on first conflict", () => {
|
||||
const result = resolveCreatureName("Goblin", ["Orc", "Goblin", "Dragon"]);
|
||||
expect(result).toEqual({
|
||||
newName: "Goblin 2",
|
||||
renames: [{ from: "Goblin", to: "Goblin 1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("appends next number when numbered variants already exist", () => {
|
||||
const result = resolveCreatureName("Goblin", ["Goblin 1", "Goblin 2"]);
|
||||
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
|
||||
});
|
||||
|
||||
it("handles mixed exact and numbered matches", () => {
|
||||
const result = resolveCreatureName("Goblin", [
|
||||
"Goblin",
|
||||
"Goblin 1",
|
||||
"Goblin 2",
|
||||
]);
|
||||
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
|
||||
});
|
||||
|
||||
it("handles names with special regex characters", () => {
|
||||
const result = resolveCreatureName("Goblin (Boss)", ["Goblin (Boss)"]);
|
||||
expect(result).toEqual({
|
||||
newName: "Goblin (Boss) 2",
|
||||
renames: [{ from: "Goblin (Boss)", to: "Goblin (Boss) 1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not match partial name overlaps", () => {
|
||||
const result = resolveCreatureName("Goblin", ["Goblin Boss"]);
|
||||
expect(result).toEqual({ newName: "Goblin", renames: [] });
|
||||
});
|
||||
});
|
||||
54
packages/domain/src/auto-number.ts
Normal file
54
packages/domain/src/auto-number.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Resolves a creature name against existing combatant names,
|
||||
* handling auto-numbering for duplicates.
|
||||
*
|
||||
* - No conflict: returns name as-is, no renames.
|
||||
* - First conflict (one existing match): renames existing to "Name 1",
|
||||
* new becomes "Name 2".
|
||||
* - Subsequent conflicts: new gets next number suffix.
|
||||
*/
|
||||
export function resolveCreatureName(
|
||||
baseName: string,
|
||||
existingNames: readonly string[],
|
||||
): {
|
||||
newName: string;
|
||||
renames: ReadonlyArray<{ from: string; to: string }>;
|
||||
} {
|
||||
// Find exact matches and numbered matches (e.g., "Goblin 1", "Goblin 2")
|
||||
const exactMatches: number[] = [];
|
||||
let maxNumber = 0;
|
||||
|
||||
for (let i = 0; i < existingNames.length; i++) {
|
||||
const name = existingNames[i];
|
||||
if (name === baseName) {
|
||||
exactMatches.push(i);
|
||||
} else {
|
||||
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
|
||||
if (match) {
|
||||
const num = Number.parseInt(match[1], 10);
|
||||
if (num > maxNumber) maxNumber = num;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No conflict at all
|
||||
if (exactMatches.length === 0 && maxNumber === 0) {
|
||||
return { newName: baseName, renames: [] };
|
||||
}
|
||||
|
||||
// First conflict: one exact match, no numbered ones yet
|
||||
if (exactMatches.length === 1 && maxNumber === 0) {
|
||||
return {
|
||||
newName: `${baseName} 2`,
|
||||
renames: [{ from: baseName, to: `${baseName} 1` }],
|
||||
};
|
||||
}
|
||||
|
||||
// Subsequent conflicts: append next number
|
||||
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
||||
return { newName: `${baseName} ${nextNumber}`, renames: [] };
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
91
packages/domain/src/creature-types.ts
Normal file
91
packages/domain/src/creature-types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** Branded string type for creature identity. */
|
||||
export type CreatureId = string & { readonly __brand: "CreatureId" };
|
||||
|
||||
export function creatureId(id: string): CreatureId {
|
||||
return id as CreatureId;
|
||||
}
|
||||
|
||||
export interface TraitBlock {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface LegendaryBlock {
|
||||
readonly preamble: string;
|
||||
readonly entries: readonly TraitBlock[];
|
||||
}
|
||||
|
||||
export interface DailySpells {
|
||||
readonly uses: number;
|
||||
readonly each: boolean;
|
||||
readonly spells: readonly string[];
|
||||
}
|
||||
|
||||
export interface SpellcastingBlock {
|
||||
readonly name: string;
|
||||
readonly headerText: string;
|
||||
readonly atWill?: readonly string[];
|
||||
readonly daily?: readonly DailySpells[];
|
||||
readonly restLong?: readonly DailySpells[];
|
||||
}
|
||||
|
||||
export interface BestiarySource {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly tag?: string;
|
||||
}
|
||||
|
||||
export interface Creature {
|
||||
readonly id: CreatureId;
|
||||
readonly name: string;
|
||||
readonly source: string;
|
||||
readonly sourceDisplayName: string;
|
||||
readonly size: string;
|
||||
readonly type: string;
|
||||
readonly alignment: string;
|
||||
readonly ac: number;
|
||||
readonly acSource?: string;
|
||||
readonly hp: { readonly average: number; readonly formula: string };
|
||||
readonly speed: string;
|
||||
readonly abilities: {
|
||||
readonly str: number;
|
||||
readonly dex: number;
|
||||
readonly con: number;
|
||||
readonly int: number;
|
||||
readonly wis: number;
|
||||
readonly cha: number;
|
||||
};
|
||||
readonly cr: string;
|
||||
readonly proficiencyBonus: number;
|
||||
readonly passive: number;
|
||||
readonly savingThrows?: string;
|
||||
readonly skills?: string;
|
||||
readonly resist?: string;
|
||||
readonly immune?: string;
|
||||
readonly vulnerable?: string;
|
||||
readonly conditionImmune?: string;
|
||||
readonly senses?: string;
|
||||
readonly languages?: string;
|
||||
readonly traits?: readonly TraitBlock[];
|
||||
readonly actions?: readonly TraitBlock[];
|
||||
readonly bonusActions?: readonly TraitBlock[];
|
||||
readonly reactions?: readonly TraitBlock[];
|
||||
readonly legendaryActions?: LegendaryBlock;
|
||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||
}
|
||||
|
||||
/** Maps a CR string to the corresponding proficiency bonus. */
|
||||
export function proficiencyBonus(cr: string): number {
|
||||
const numericCr = cr.includes("/")
|
||||
? Number(cr.split("/")[0]) / Number(cr.split("/")[1])
|
||||
: Number(cr);
|
||||
|
||||
if (numericCr <= 4) return 2;
|
||||
if (numericCr <= 8) return 3;
|
||||
if (numericCr <= 12) return 4;
|
||||
if (numericCr <= 16) return 5;
|
||||
if (numericCr <= 20) return 6;
|
||||
if (numericCr <= 24) return 7;
|
||||
if (numericCr <= 28) return 8;
|
||||
return 9;
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
||||
export { advanceTurn } from "./advance-turn.js";
|
||||
export { resolveCreatureName } from "./auto-number.js";
|
||||
export {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionDefinition,
|
||||
type ConditionId,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
type BestiarySource,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
creatureId,
|
||||
type DailySpells,
|
||||
type LegendaryBlock,
|
||||
proficiencyBonus,
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
editCombatant,
|
||||
|
||||
@@ -6,6 +6,7 @@ export function combatantId(id: string): CombatantId {
|
||||
}
|
||||
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -16,6 +17,7 @@ export interface Combatant {
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
Reference in New Issue
Block a user