8000 fix(character): insert character row before provisioning to satisfy inventory FK by Mnehmos · Pull Request #55 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 59 additions & 40 deletions src/server/consolidated/character-manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,23 +149,26 @@ const LevelUpSchema = z.object({
// ═══════════════════════════════════════════════════════════════════════════

/**
* Convert spell slots from array format [0, slots1, slots2, ...] to object format
* The array format from provisionStartingEquipment has index 0 as cantrips (unused here),
* and indices 1-9 as spell levels 1-9.
* Convert spell slots from the zero-indexed array returned by getSpellSlots
* (slots[0] = level-1 slot count, slots[1] = level-2, …) into the object
* shape persisted on the character row.
*
* NOTE: This fix is duplicated in PR #54 (issue #44). It must land here too,
* or every wizard/cleric this PR persists will be off by one slot level.
*/
function convertSpellSlotsToObject(slots: number[] | null) {
if (!slots || slots.length === 0) return undefined;

return {
level1: { current: slots[1] || 0, max: slots[1] || 0 },
level2: { current: slots[2] || 0, max: slots[2] || 0 },
level3: { current: slots[3] || 0, max: slots[3] || 0 },
level4: { current: slots[4] || 0, max: slots[4] || 0 },
level5: { current: slots[5] || 0, max: slots[5] || 0 },
level6: { current: slots[6] || 0, max: slots[6] || 0 },
level7: { current: slots[7] || 0, max: slots[7] || 0 },
level8: { current: slots[8] || 0, max: slots[8] || 0 },
level9: { current: slots[9] || 0, max: slots[9] || 0 }
level1: { current: slots[0] || 0, max: slots[0] || 0 },
level2: { current: slots[1] || 0, max: slots[1] || 0 },
level3: { current: slots[2] || 0, max: slots[2] || 0 },
level4: { current: slots[3] || 0, max: slots[3] || 0 },
level5: { current: slots[4] || 0, max: slots[4] || 0 },
level6: { current: slots[5] || 0, max: slots[5] || 0 },
level7: { current: slots[6] || 0, max: slots[6] || 0 },
level8: { current: slots[7] || 0, max: slots[7] || 0 },
level9: { current: slots[8] || 0, max: slots[8] || 0 }
};
}

Expand All @@ -185,27 +188,10 @@ async function handleCreate(args: z.infer<typeof CreateSchema>): Promise<object>
const maxHp = args.maxHp ?? hp;
const characterId = randomUUID();

// Provision starting equipment and spells if enabled
let provisioningResult = null;
const shouldProvision = args.provisionEquipment !== false &&
(args.characterType === 'pc' || args.characterType === undefined);

if (shouldProvision) {
provisioningResult = provisionStartingEquipment(
db,
characterId,
className,
args.level ?? 1,
{
customEquipment: args.customEquipment,
customSpells: args.knownSpells?.length ? args.knownSpells : undefined,
startingGold: args.startingGold
}
);
}

// Build character
const character = {
// Build the base character record from args. The character row MUST be
// inserted before provisioning runs, otherwise inventory_items.character_id
// FK fails when the provisioner tries to grant starting equipment.
const character: Record<string, unknown> = {
id: characterId,
name: args.name,
race: args.race,
Expand All @@ -220,23 +206,56 @@ async function handleCreate(args: z.infer<typeof CreateSchema>): Promise<object>
characterType: args.characterType ?? 'pc',
factionId: args.factionId,
behavior: args.behavior,
knownSpells: provisioningResult?.spellsGranted.length
? [...new Set([...args.knownSpells || [], ...provisioningResult.spellsGranted])]
: args.knownSpells || [],
cantripsKnown: provisioningResult?.cantripsGranted || [],
knownSpells: args.knownSpells || [],
cantripsKnown: [],
preparedSpells: args.preparedSpells || [],
resistances: args.resistances || [],
vulnerabilities: args.vulnerabilities || [],
immunities: args.immunities || [],
spellSlots: convertSpellSlotsToObject(provisioningResult?.spellSlots ?? null),
pactMagicSlots: provisioningResult?.pactMagicSlots || undefined,
spellSlots: undefined,
pactMagicSlots: undefined,
xp: 0,
createdAt: now,
updatedAt: now
};

characterRepo.create(character as any);

// Now safe to provision: character row exists, so FK on inventory_items.character_id resolves.
let provisioningResult = null;
const shouldProvision = args.provisionEquipment !== false &&
(args.characterType === 'pc' || args.characterType === undefined);

if (shouldProvision) {
provisioningResult = provisionStartingEquipment(
db,
characterId,
className,
args.level ?? 1,
{
customEquipment: args.customEquipment,
customSpells: args.knownSpells?.length ? args.knownSpells : undefined,
startingGold: args.startingGold
}
);

// Roll spell-related fields from provisioning into the in-memory record
// and persist via update so the character row stays consistent.
character.knownSpells = provisioningResult.spellsGranted.length
? [...new Set([...(args.knownSpells || []), ...provisioningResult.spellsGranted])]
: args.knownSpells || [];
character.cantripsKnown = provisioningResult.cantripsGranted || [];
character.spellSlots = convertSpellSlotsToObject(provisioningResult.spellSlots ?? null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
character.pactMagicSlots = provisioningResult.pactMagicSlots || undefined;

characterRepo.update(characterId, {
knownSpells: character.knownSpells as string[],
cantripsKnown: character.cantripsKnown as string[],
spellSlots: character.spellSlots,
pactMagicSlots: character.pactMagicSlots
} as any);
}

const response: Record<string, unknown> = { ...character, success: true };
if (provisioningResult) {
response._provisioning = {
Expand Down
38 changes: 38 additions & 0 deletions tests/server/consolidated/character-manage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,44 @@ describe('character_manage consolidated tool', () => {
expect(parsed._provisioning).toBeDefined();
});

// Regression for issue #45: provisioning ran before character row was
// inserted, so every starting-item grant failed FOREIGN KEY check on
// inventory_items.character_id and characters spawned with empty bags.
it('actually grants starting equipment without FK errors', async () => {
const classes = ['Paladin', 'Rogue', 'Wizard', 'Cleric', 'Fighter'];
for (const klass of classes) {
const result = await handleCharacterManage({
action: 'create',
name: `${klass}-Equip`,
class: klass,
level: 4
}, ctx);
const parsed = extractJson(result.content[0].text);
expect(parsed._provisioning, `${klass}: missing _provisioning`).toBeDefined();
expect(parsed._provisioning.errors, `${klass}: equipment errors leaked`).toBeUndefined();
expect(parsed._provisioning.equipmentGranted.length, `${klass}: nothing granted`).toBeGreaterThan(0);
}
});

// 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
// persisted 0 / 3 first-level slots respectively.
it('persists spell slots from the correct array index after the FK reorder', async () => {
const wizard = await handleCharacterManage({
action: 'create', name: 'IndexCheck-Wizard', class: 'Wizard', level: 4
}, ctx);
const w = extractJson(wizard.content[0].text);
expect(w.spellSlots.level1.max).toBe(4);
expect(w.spellSlots.level2.max).toBe(3);

const paladin = await handleCharacterManage({
action: 'create', name: 'IndexCheck-Paladin', class: 'Paladin', level: 4
}, ctx);
const p = extractJson(paladin.content[0].text);
expect(p.spellSlots.level1.max).toBe(3);
});

it('should skip provisioning when provisionEquipment is false', async () => {
const result = await handleCharacterManage({
action: 'create',
Expand Down
Loading
0