Skip to content

Commit 0a54b64

Browse files
SociableSteveclaude
andcommitted
Always keep a caster's signature attack cantrip
Thematic selection could drop a sorcerer/wizard/warlock's attack cantrip, leaving the sheet's attack line (e.g. Fire Bolt) referencing a spell the character no longer knew. buildSpellcasting now reserves one cantrip slot for the class's signature attack cantrip, and ensureCharacterFields repairs already-curated casters that are missing it (even when their counts are otherwise correct). 41 server tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c01ce56 commit 0a54b64

3 files changed

Lines changed: 46 additions & 4 deletions

File tree

packages/server/src/backend.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getAdventureTemplate } from "./adventureTemplates.js";
1313
import { buildStateContext } from "./dm.js";
1414
import { addCharacterToSession, appendLog, createSession, fromSnapshot, toSnapshot } from "./session.js";
1515
import { buildCharacter, ensureCharacterFields } from "./srd.js";
16-
import { buildSpellcasting, spellCounts, startingInventory, topUpBackgroundGear } from "./srdContent.js";
16+
import { buildSpellcasting, spellCounts, spellPool, startingInventory, topUpBackgroundGear } from "./srdContent.js";
1717
import { chooseSpells, suggestCharacterBuild } from "./suggest.js";
1818
import { JsonStore, newId, type Character, type User } from "./store.js";
1919

@@ -144,6 +144,32 @@ describe("character sheet: spells, inventory, gold", () => {
144144
expect(fixed.map((s) => s.name)).toEqual(expect.arrayContaining(leveled.slice(0, 2).map((s) => s.name)));
145145
});
146146

147+
it("always keeps the class's signature attack cantrip", () => {
148+
// Even when the chosen spells are all utility, Fire Bolt is reserved.
149+
const sc = buildSpellcasting("sorcerer", 1, ARRAY, 2, ["Prestidigitation", "Mage Hand", "Light", "Minor Illusion"])!;
150+
expect(sc.known.some((s) => s.name === "Fire Bolt")).toBe(true);
151+
});
152+
153+
it("repairs a curated caster missing its attack cantrip (count already correct)", () => {
154+
const d = buildCharacter({
155+
name: "Grafton", raceId: "human", classId: "sorcerer", backgroundId: "sage",
156+
level: 1, baseScores: ARRAY, chosenSkills: ["arcana", "insight"],
157+
});
158+
// Correct counts (4 cantrips + 2 leveled) but no Fire Bolt — the old re-roll.
159+
d.creature.spellcasting!.known = [
160+
...spellPool("sorcerer").filter((s) => s.level === 0 && s.name !== "Fire Bolt").slice(0, 4),
161+
...spellPool("sorcerer").filter((s) => s.level === 1).slice(0, 2),
162+
];
163+
const ch: Character = {
164+
id: d.creature.id, userId: "u", name: d.creature.name, createdAt: 0, spellsCurated: true,
165+
meta: { race: "Human", className: "Sorcerer", background: "Sage", level: 1 },
166+
sheet: { creature: d.creature, attack: d.attack },
167+
};
168+
expect(ensureCharacterFields(ch)).toBe(true);
169+
expect(ch.sheet.creature.spellcasting!.known.some((s) => s.name === "Fire Bolt")).toBe(true);
170+
expect(ensureCharacterFields(ch)).toBe(false); // idempotent
171+
});
172+
147173
it("builds inventory from class + background and tops it up idempotently", () => {
148174
const inv = startingInventory("sorcerer", "sage");
149175
expect(inv.some((i) => i.name === "Arcane Focus")).toBe(true); // class kit

packages/server/src/srd.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type Roleplay,
1717
type Skill,
1818
} from "@ai-dm/engine";
19-
import { buildSpellcasting, spellCounts, startingGold, startingInventory, topUpBackgroundGear } from "./srdContent.js";
19+
import { ATTACK_CANTRIP, buildSpellcasting, spellCounts, startingGold, startingInventory, topUpBackgroundGear } from "./srdContent.js";
2020
import { type Character, newId } from "./store.js";
2121

2222
export interface RaceDef {
@@ -246,7 +246,11 @@ export function ensureCharacterFields(character: Character): boolean {
246246
const have = c.spellcasting.known;
247247
const cantrips = have.filter((s) => s.level === 0).length;
248248
const leveled = have.length - cantrips;
249-
if (cantrips !== want.cantrips || leveled !== want.leveled) {
249+
// Also repair casters that lack their signature attack cantrip (the sheet's
250+
// attack would otherwise reference a spell they don't know).
251+
const attackCantrip = ATTACK_CANTRIP[classId];
252+
const missingAttack = !!attackCantrip && !have.some((s) => s.name === attackCantrip);
253+
if (cantrips !== want.cantrips || leveled !== want.leveled || missingAttack) {
250254
const sc = buildSpellcasting(classId, character.meta.level, c.abilities, c.proficiencyBonus, have.map((s) => s.name));
251255
if (sc) {
252256
c.spellcasting = sc;

packages/server/src/srdContent.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ const CANTRIPS_KNOWN: Record<string, number[]> = {
215215
warlock: [0, 2, 2, 2, 3, 3],
216216
};
217217

218+
// Classes whose primary attack IS a cantrip must always know that cantrip, so
219+
// the sheet's attack line stays consistent with their known spells.
220+
export const ATTACK_CANTRIP: Record<string, string> = {
221+
wizard: "Fire Bolt",
222+
sorcerer: "Fire Bolt",
223+
warlock: "Eldritch Blast",
224+
};
225+
218226
// Leveled "spells known" for known-casters (others prepare; see below).
219227
const SPELLS_KNOWN: Record<string, number[]> = {
220228
sorcerer: [0, 2, 3, 4, 5, 6],
@@ -299,8 +307,12 @@ export function buildSpellcasting(
299307

300308
const eligible = eligibleSpells(classId, level);
301309
const want = spellCounts(classId, level, abilities);
310+
// Reserve a cantrip for the class's signature attack cantrip when its primary
311+
// attack is a spell, so the character can always make their basic attack.
312+
const attackCantrip = ATTACK_CANTRIP[classId];
313+
const cantripPicks = attackCantrip ? [attackCantrip, ...(chosen ?? [])] : chosen;
302314
const known = [
303-
...selectSpells(eligible.cantrips, chosen, want.cantrips),
315+
...selectSpells(eligible.cantrips, cantripPicks, want.cantrips),
304316
...selectSpells(eligible.leveled, chosen, want.leveled),
305317
];
306318
const mod = abilityModifier(abilities[def.ability]);

0 commit comments

Comments
 (0)