From e8f02c6c94c4d227a424d4a36714d23f8a9d76a9 Mon Sep 17 00:00:00 2001 From: Mnehmos Date: Sat, 18 Apr 2026 18:35:52 -0700 Subject: [PATCH 1/2] feat(character): level_up now recomputes spell slots; L1-L9 regression Reviewers (PR #54) asked for L1-L9 coverage and a level_up assertion. Investigating revealed handleLevelUp never recomputed spell slots, so a wizard hitting L5 wouldn't gain their first 3rd-level slot, and a paladin hitting L2 stayed at zero. Same bug class as #44. - handleLevelUp: when the class is a spellcaster, recompute slots via getSpellSlots + convertSpellSlotsToObject and persist them. - Tests: parameterized create checks for Wizard/Cleric/Paladin at every level 1-9 against PHB tables (full and half caster). - Tests: level_up Wizard L4 -> L5 grows level1=4 / level2=3 / level3=2 and Paladin L1 -> L2 picks up two level-1 slots. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/consolidated/character-manage.ts | 12 +++ .../consolidated/character-manage.test.ts | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/server/consolidated/character-manage.ts b/src/server/consolidated/character-manage.ts index 333678c..3cafbfc 100644 --- a/src/server/consolidated/character-manage.ts +++ b/src/server/consolidated/character-manage.ts @@ -18,6 +18,7 @@ import { SessionContext } from '../types.js'; import { getDb } from '../../storage/index.js'; import { CharacterRepository } from '../../storage/repos/character.repo.js'; import { provisionStartingEquipment } from '../../services/starting-equipment.service.js'; +import { getSpellSlots, isSpellcaster } from '../../data/class-starting-data.js'; import { createActionRouter, ActionDefinition, McpResponse } from '../../utils/action-router.js'; import { RichFormatter } from '../utils/formatter.js'; @@ -445,6 +446,16 @@ async function handleLevelUp(args: z.infer): Promise): Promise { } }); + // Regression for issue #44: spell slot array was being read with the + // wrong index (slots[1] for level1, etc.), so half-casters got nothing + // and full casters reported one level too low. Reviewers asked for + // L1–L9 coverage on the create path plus a level_up assertion. + + // Source-of-truth tables (PHB). Indexes 0..8 = level1..level9 slots. + const FULL_CASTER: Record = { + 1: [2, 0, 0, 0, 0, 0, 0, 0, 0], + 2: [3, 0, 0, 0, 0, 0, 0, 0, 0], + 3: [4, 2, 0, 0, 0, 0, 0, 0, 0], + 4: [4, 3, 0, 0, 0, 0, 0, 0, 0], + 5: [4, 3, 2, 0, 0, 0, 0, 0, 0], + 6: [4, 3, 3, 0, 0, 0, 0, 0, 0], + 7: [4, 3, 3, 1, 0, 0, 0, 0, 0], + 8: [4, 3, 3, 2, 0, 0, 0, 0, 0], + 9: [4, 3, 3, 3, 1, 0, 0, 0, 0] + }; + const HALF_CASTER: Record = { + 1: [0, 0, 0, 0, 0, 0, 0, 0, 0], + 2: [2, 0, 0, 0, 0, 0, 0, 0, 0], + 3: [3, 0, 0, 0, 0, 0, 0, 0, 0], + 4: [3, 0, 0, 0, 0, 0, 0, 0, 0], + 5: [4, 2, 0, 0, 0, 0, 0, 0, 0], + 6: [4, 2, 0, 0, 0, 0, 0, 0, 0], + 7: [4, 3, 0, 0, 0, 0, 0, 0, 0], + 8: [4, 3, 0, 0, 0, 0, 0, 0, 0], + 9: [4, 3, 2, 0, 0, 0, 0, 0, 0] + }; + + function assertSlots(actual: any, expected: number[], label: string) { + for (let i = 0; i < 9; i++) { + const key = `level${i + 1}` as const; + expect(actual[key].max, `${label} ${key}.max`).toBe(expected[i]); + expect(actual[key].current, `${label} ${key}.current`).toBe(expected[i]); + } + } + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9])( + 'seeds Wizard L%i with full-caster slots', + async (level) => { + const result = await handleCharacterManage({ + action: 'create', name: `Wizard-L${level}`, class: 'Wizard', level + }, ctx); + const parsed = extractJson(result.content[0].text); + assertSlots(parsed.spellSlots, FULL_CASTER[level], `Wizard L${level}`); + } + ); + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9])( + 'seeds Cleric L%i with full-caster slots', + async (level) => { + const result = await handleCharacterManage({ + action: 'create', name: `Cleric-L${level}`, class: 'Cleric', level + }, ctx); + const parsed = extractJson(result.content[0].text); + assertSlots(parsed.spellSlots, FULL_CASTER[level], `Cleric L${level}`); + } + ); + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9])( + 'seeds Paladin L%i with half-caster slots', + async (level) => { + const result = await handleCharacterManage({ + action: 'create', name: `Paladin-L${level}`, class: 'Paladin', level + }, ctx); + const parsed = extractJson(result.content[0].text); + assertSlots(parsed.spellSlots, HALF_CASTER[level], `Paladin L${level}`); + } + ); + + it('level_up recomputes spell slots so a wizard going L4 → L5 gains 2nd-level slot', async () => { + const create = await handleCharacterManage({ + action: 'create', name: 'Aspiring Wizard', class: 'Wizard', level: 4 + }, ctx); + const created = extractJson(create.content[0].text); + assertSlots(created.spellSlots, FULL_CASTER[4], 'Wizard L4 (create)'); + + const lu = await handleCharacterManage({ + action: 'level_up', characterId: created.id, targetLevel: 5 + }, ctx); + const leveled = extractJson(lu.content[0].text); + expect(leveled.newLevel).toBe(5); + assertSlots(leveled.spellSlots, FULL_CASTER[5], 'Wizard L5 (level_up)'); + }); + + it('level_up grants paladin their first spell slots when crossing L1 → L2', async () => { + const create = await handleCharacterManage({ + action: 'create', name: 'Squire', class: 'Paladin', level: 1 + }, ctx); + const created = extractJson(create.content[0].text); + assertSlots(created.spellSlots, HALF_CASTER[1], 'Paladin L1 (create)'); + + const lu = await handleCharacterManage({ + action: 'level_up', characterId: created.id, targetLevel: 2 + }, ctx); + const leveled = extractJson(lu.content[0].text); + assertSlots(leveled.spellSlots, HALF_CASTER[2], 'Paladin L2 (level_up)'); + }); + // Reviewer follow-up: with provisioning now running after the character // is inserted, also confirm that the slot-array → slot-object conversion // is zero-indexed. Without this fix bundled in, paladin L4 / wizard L4 From 528d5b6207afffb01df13cb9ff085d69c7644804 Mon Sep 17 00:00:00 2001 From: Mnehmos Date: Sun, 19 Apr 2026 12:38:11 -0700 Subject: [PATCH 2/2] fix(character): drop char.class fallback that broke tsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #54 failed with TS2339 because Character only exposes characterClass — char.class doesn't exist on the type. The defensive fallback was unnecessary; the schema is consistent. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/consolidated/character-manage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/consolidated/character-manage.ts b/src/server/consolidated/character-manage.ts index 3cafbfc..2ad528a 100644 --- a/src/server/consolidated/character-manage.ts +++ b/src/server/consolidated/character-manage.ts @@ -449,7 +449,7 @@ async function handleLevelUp(args: z.infer): Promise