8000 fix(inventory): add authoritative light extinguish by Mnehmos · Pull Request #105 · 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
82 changes: 80 additions & 2 deletions src/server/consolidated/inventory-manage.ts
10000
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Consolidated Inventory Management Tool
* Replaces 8 separate tools: give_item, remove_item, transfer_item, use_item, equip_item, unequip_item, get_inventory, get_inventory_detailed
* Replaces 9 separate tools: give_item, remove_item, transfer_item, use_item, extinguish_light, equip_item, unequip_item, get_inventory, get_inventory_detailed
*/

import { z } from 'zod';
Expand All @@ -17,7 +17,7 @@
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════

const ACTIONS = ['give', 'remove', 'transfer', 'use', 'equip', 'unequip', 'get', 'get_detailed'] as const;
const ACTIONS = ['give', 'remove', 'transfer', 'use', 'extinguish', 'equip', 'unequip', 'get', 'get_detailed'] as const;
type InventoryAction = typeof ACTIONS[number];

// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -150,6 +150,12 @@
targetId: z.string().optional().describe('Optional target character for the effect')
});

const ExtinguishSchema = z.object({
action: z.literal('extinguish'),
characterId: z.string().describe('Character carrying or benefiting from the light source'),
itemId: z.string().describe('The light-source item to extinguish')
});

const EquipSchema = z.object({
action: z.literal('equip'),
characterId: z.string().describe('Character equipping the item'),
Expand Down Expand Up @@ -440,6 +446,69 @@
aliases: ['consume', 'apply', 'activate']
},

extinguish: {
schema: ExtinguishSchema,
handler: async (params: z.infer<typeof ExtinguishSchema>) => {
const { itemRepo, effectsRepo } = ensureDb();
const item = itemRepo.findById(params.itemId);
if (!item) {
throw new Error(`Item not found: ${params.itemId}`);
}

const lightSource = getLightSourceProfile(item);
if (!lightSource) {
throw new Error(`Item "${item.name}" is not a recognized light source`);
}

const effectName = `Light source: ${item.name}`;
const activeEffect = effectsRepo.findByTargetAndName(params.characterId, 'character', effectName);
Comment on lines +463 to +464
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match the persisted light by source item ID

When two light-source templates share a name, extinguishing the never-lit template will find and deactivate the other template's effect; similarly, item_manage.update can rename a lit template, after which this lookup reports it as already extinguished while the authoritative effect remains active. The effect already persists the stable item identity in source_entity_id, so the lookup should match the target and that ID rather than the mutable, non-unique display name.

Useful? React with 👍 / 👎.

const provenance = {
itemId: item.id,
itemName: item.name,
open5e: item.properties?.open5e && typeof item.properties.open5e === 'object'
? item.properties.open5e
: null,
};

if (!activeEffect) {
return {
success: true,
actionType: 'extinguish',
characterId: params.characterId,
itemName: item.name,
lightSource: {
...lightSource,
active: false,
effectId: null,
expiresAt: null,
provenance,
},
alreadyExtinguished: true,
message: `${item.name} is already extinguished`,
};
}

const extinguished = effectsRepo.deactivate(activeEffect.id);
return {
success: true,
actionType: 'extinguish',
characterId: params.characterId,
itemName: item.name,
effectId: extinguished?.id ?? activeEffect.id,
lightSource: {
...lightSource,
active: false,
effectId: extinguished?.id ?? activeEffect.id,
expiresAt: activeEffect.expires_at,
provenance,
},
extinguished: true,
message: `Extinguished ${item.name}`,
};
},
aliases: ['put_out', 'douse']
},

equip: {
schema: EquipSchema,
handler: async (params: z.infer<typeof EquipSchema>) => {
Expand Down Expand Up @@ -599,6 +668,7 @@
🔄 COMMON ACTIONS:
- transfer: Move items between characters atomically (use this for a player-to-NPC handoff)
- use: Consume potions/scrolls or light a torch/lantern (persists duration and provenance)
- extinguish: Put out an active torch/lantern without consuming or removing the source item
- get_detailed: Show weight, capacity, and item details

IMPORTANT: give is a world/DM grant to one character and does not remove an item from another character. For a handoff, always use transfer with fromCharacterId and toCharacterId.
Expand All @@ -625,104 +695,112 @@
// HANDLER
// ═══════════════════════════════════════════════════════════════════════════

export async function handleInventoryManage(args: unknown, _ctx: SessionContext): Promise<McpResponse> {
const response = await router(args as Record<string, unknown>);

// Wrap response with ASCII formatting
try {
const parsed = JSON.parse(response.content[0].text);
let output = '';

if (parsed.error) {
output = RichFormatter.header('Inventory Error', '❌');
output += RichFormatter.alert(parsed.message || 'Unknown error', 'error');
if (parsed.suggestions) {
output += RichFormatter.section('Did you mean?');
parsed.suggestions.forEach((s: { value: string; similarity: number }) => {
output += ` • ${s.value} (${s.similarity}% match)\n`;
});
}
if (parsed.validActions) {
output += RichFormatter.section('Valid Actions');
output += RichFormatter.list(parsed.validActions);
}
} else if (parsed.actionType === 'give' || parsed.actionType === 'remove') {
output = RichFormatter.header(parsed.actionType === 'give' ? 'Item Added' : 'Item Removed', parsed.actionType === 'give' ? '➕' : '➖');
output += RichFormatter.keyValue({
'Item': parsed.itemName,
'Quantity': parsed.quantity,
'Character': parsed.characterId,
});
output += RichFormatter.success(parsed.message);
} else if (parsed.actionType === 'transfer') {
output = RichFormatter.header('Item Transferred', '🔀');
output += RichFormatter.keyValue({
'Item': parsed.itemName,
'Quantity': parsed.quantity,
'From': parsed.fromCharacterId,
'To': parsed.toCharacterId,
});
output += RichFormatter.success(parsed.message);
} else if (parsed.actionType === 'use') {
output = RichFormatter.header(parsed.lightSource ? 'Light Source Lit' : 'Item Used', parsed.lightSource ? '🔥' : '✨');
output += RichFormatter.keyValue({
'Item': parsed.itemName,
'Target': parsed.targetId,
});
output += RichFormatter.section('Effect');
output += `${parsed.effect}\n`;
if (parsed.lightSource) {
output += RichFormatter.keyValue({
'Bright': `${parsed.lightSource.brightRadiusFeet} ft`,
'Dim': `${parsed.lightSource.dimRadiusFeet} ft`,
'Duration': `${parsed.lightSource.durationMinutes} minutes`,
'Effect ID': parsed.lightSource.effectId,
});
}
output += RichFormatter.success(parsed.message);
} else if (parsed.actionType === 'extinguish') {
output = RichFormatter.header('Light Source Extinguished', '🕯️');
output += RichFormatter.keyValue({
'Item': parsed.itemName,
'Character': parsed.characterId,
'Effect ID': parsed.effectId,
});
output += RichFormatter.success(parsed.message);
} else if (parsed.actionType === 'equip' || parsed.actionType === 'unequip') {
output = RichFormatter.header(parsed.actionType === 'equip' ? 'Item Equipped' : 'Item Unequipped', parsed.actionType === 'equip' ? '⚔️' : '📦');
output += RichFormatter.keyValue({
'Item': parsed.itemName,
'Character': parsed.characterId,
...(parsed.slot && { 'Slot': parsed.slot }),
});
if (parsed.acChange) {
output += RichFormatter.alert(parsed.acChange, 'info');
}
output += RichFormatter.success(parsed.message);
} else if (parsed.actionType === 'get' || parsed.actionType === 'get_detailed') {
output = RichFormatter.header('Inventory', '🎒');
output += RichFormatter.keyValue({
'Character': parsed.characterId,
...(parsed.totalWeight !== undefined && {
'Weight': `${parsed.totalWeight}/${parsed.capacity} lbs`
}),
...(parsed.gold !== undefined && { 'Gold': parsed.gold }),
'Items': parsed.itemCount || 0
});
if (parsed.inventory?.length) {
output += RichFormatter.inventory(parsed.inventory.map((i: { item?: { name: string }; itemId: string; quantity: number; equipped: boolean; slot?: string }) => ({
name: i.item?.name || i.itemId,
quantity: i.quantity,
equipped: i.equipped,
slot: i.slot,
})));
} else {
output += '*Inventory is empty*\n';
}
} else {
// Fallback
output = RichFormatter.header('Inventory Operation', '🎒');
output += JSON.stringify(parsed, null, 2) + '\n';
}

// Embed JSON for programmatic access
output += RichFormatter.embedJson(parsed, 'INVENTORY_MANAGE');

return { content: [{ type: 'text', text: output }] };
} catch {
// If JSON parsing fails, return original response
return response;
}
}

Check warning on line 806 in src/server/consolidated/inventory-manage.ts

View check run for this annotation

Repowise Bot / Repowise / code health

Introduced: brain method

Brain Method: handleInventoryManage is 101 lines, CCN 29, in a file imported by 4 others
60 changes: 59 additions & 1 deletion tests/server/consolidated/inventory-manage.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Tests for consolidated inventory_manage tool
* Validates all 8 actions: give, remove, transfer, use, equip, unequip, get, get_detailed
* Validates all 9 actions: give, remove, transfer, use, extinguish, equip, unequip, get, get_detailed
*/

import { handleInventoryManage, InventoryManageTool } from '../../../src/server/consolidated/inventory-manage.js';
Expand Down Expand Up @@ -79,6 +79,7 @@ describe('inventory_manage consolidated tool', () => {
expect(InventoryManageTool.description).toContain('remove');
expect(InventoryManageTool.description).toContain('transfer');
expect(InventoryManageTool.description).toContain('use');
expect(InventoryManageTool.description).toContain('extinguish');
expect(InventoryManageTool.description).toContain('equip');
expect(InventoryManageTool.description).toContain('unequip');
expect(InventoryManageTool.description).toContain('get');
Expand Down Expand Up @@ -443,6 +444,63 @@ describe('inventory_manage consolidated tool', () => {
}, ctx));
expect(inventory.inventory.find((entry: { item: { id: string } }) => entry.item.id === lanternId).quantity).toBe(1);
});

it('extinguishes a light source, preserves the source item, and is idempotent', async () => {
const torchResult = await handleItemManage({
action: 'create',
name: 'Torch',
type: 'misc',
weight: 1,
value: 0.01,
}, ctx);
const torchId = parseItemResult(torchResult).item.id;
await handleInventoryManage({
action: 'give',
characterId: testCharId,
itemId: torchId,
quantity: 1,
}, ctx);
await handleInventoryManage({
action: 'use',
characterId: testCharId,
itemId: torchId,
}, ctx);

const extinguished = parseResult(await handleInventoryManage({
action: 'extinguish',
characterId: testCharId,
itemId: torchId,
}, ctx));

expect(extinguished).toMatchObject({
success: true,
actionType: 'extinguish',
extinguished: true,
lightSource: {
kind: 'torch',
active: false,
effectId: expect.any(Number),
provenance: { itemId: torchId, itemName: 'Torch' },
},
});
const repeated = parseResult(await handleInventoryManage({
action: 'extinguish',
characterId: testCharId,
itemId: torchId,
}, ctx));
expect(repeated).toMatchObject({
success: true,
actionType: 'extinguish',
alreadyExtinguished: true,
lightSource: { active: false, effectId: null },
});

const inventory = parseResult(await handleInventoryManage({
action: 'get',
characterId: testCharId,
}, ctx));
expect(inventory.inventory.find((entry: { item: { id: string }; quantity: number }) => entry.item.id === torchId)?.quantity ?? 0).toBe(0);
});
});

describe('equip action', () => {
Expand Down
Loading
0