Skip to main content
Glama
party-tools.ts24.6 kB
import { randomUUID } from 'crypto'; import { z } from 'zod'; import { PartyRepository } from '../storage/repos/party.repo.js'; import { CharacterRepository } from '../storage/repos/character.repo.js'; import { QuestRepository } from '../storage/repos/quest.repo.js'; import { Party, PartyMember, MemberRoleSchema, PartyStatusSchema, PartyContext } from '../schema/party.js'; import { getDb } from '../storage/index.js'; import { SessionContext } from './types.js'; function ensureDb() { const dbPath = process.env.NODE_ENV === 'test' ? ':memory:' : process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'; const db = getDb(dbPath); const partyRepo = new PartyRepository(db); const charRepo = new CharacterRepository(db); const questRepo = new QuestRepository(db); return { db, partyRepo, charRepo, questRepo }; } // Tool definitions export const PartyTools = { // Party CRUD CREATE_PARTY: { name: 'create_party', description: `Create a new party (adventuring group). Example: { "name": "The Fellowship", "description": "Nine companions on a quest to destroy the One Ring", "worldId": "middle-earth-id", "initialMembers": [ { "characterId": "gandalf-id", "role": "leader" }, { "characterId": "frodo-id", "role": "member" } ] }`, inputSchema: z.object({ name: z.string().min(1), description: z.string().optional(), worldId: z.string().optional(), initialMembers: z.array(z.object({ characterId: z.string(), role: MemberRoleSchema.optional().default('member'), })).optional(), }), }, GET_PARTY: { name: 'get_party', description: 'Get a party with all member details, leader, and active character info.', inputSchema: z.object({ partyId: z.string(), }), }, LIST_PARTIES: { name: 'list_parties', description: 'List all parties, optionally filtered by status or world.', inputSchema: z.object({ status: PartyStatusSchema.optional(), worldId: z.string().optional(), }), }, UPDATE_PARTY: { name: 'update_party', description: 'Update party properties (name, description, location, formation, status).', inputSchema: z.object({ partyId: z.string(), name: z.string().min(1).optional(), description: z.string().optional(), currentLocation: z.string().optional(), formation: z.string().optional(), status: PartyStatusSchema.optional(), }), }, DELETE_PARTY: { name: 'delete_party', description: 'Delete a party. Members become unassigned (not deleted).', inputSchema: z.object({ partyId: z.string(), }), }, // Member management ADD_PARTY_MEMBER: { name: 'add_party_member', description: 'Add a character to a party with role (leader, member, companion, hireling, prisoner, mount).', inputSchema: z.object({ partyId: z.string(), characterId: z.string(), role: MemberRoleSchema.optional().default('member'), position: z.number().int().optional(), notes: z.string().optional(), }), }, REMOVE_PARTY_MEMBER: { name: 'remove_party_member', description: 'Remove a character from a party.', inputSchema: z.object({ partyId: z.string(), characterId: z.string(), }), }, UPDATE_PARTY_MEMBER: { name: 'update_party_member', description: 'Update a party member\'s role, position, or notes.', inputSchema: z.object({ partyId: z.string(), characterId: z.string(), role: MemberRoleSchema.optional(), position: z.number().int().optional(), sharePercentage: z.number().int().min(0).max(100).optional(), notes: z.string().optional(), }), }, SET_PARTY_LEADER: { name: 'set_party_leader', description: 'Set the party leader. The character must already be a member.', inputSchema: z.object({ partyId: z.string(), characterId: z.string(), }), }, SET_ACTIVE_CHARACTER: { name: 'set_active_character', description: 'Set the active character (player\'s POV). The character must already be a member.', inputSchema: z.object({ partyId: z.string(), characterId: z.string(), }), }, GET_PARTY_MEMBERS: { name: 'get_party_members', description: 'Get all members of a party with their character details.', inputSchema: z.object({ partyId: z.string(), }), }, // Context for LLM GET_PARTY_CONTEXT: { name: 'get_party_context', description: 'Get party context for LLM prompts. Verbosity: minimal (~150 tokens), standard (~400), or detailed (~800).', inputSchema: z.object({ partyId: z.string(), verbosity: z.enum(['minimal', 'standard', 'detailed']).optional().default('standard'), }), }, // Utility GET_UNASSIGNED_CHARACTERS: { name: 'get_unassigned_characters', description: 'Get characters not assigned to any party. Useful for adding members.', inputSchema: z.object({ excludeEnemies: z.boolean().optional().default(true), }), }, // Party Position & Movement MOVE_PARTY: { name: 'move_party', description: 'Move a party to world map coordinates or POI. Updates location name and optional POI reference.', inputSchema: z.object({ partyId: z.string(), targetX: z.number().int().nonnegative(), targetY: z.number().int().nonnegative(), locationName: z.string().min(1), poiId: z.string().optional(), }), }, GET_PARTY_POSITION: { name: 'get_party_position', description: 'Get the current position of a party on the world map.', inputSchema: z.object({ partyId: z.string(), }), }, GET_PARTIES_IN_REGION: { name: 'get_parties_in_region', description: 'Get all parties within a certain distance of a coordinate (useful for finding nearby groups).', inputSchema: z.object({ worldId: z.string(), x: z.number().int(), y: z.number().int(), radiusSquares: z.number().int().optional().default(3), }), }, } as const; // ========== Handlers ========== export async function handleCreateParty(args: unknown, _ctx: SessionContext) { const { partyRepo, charRepo } = ensureDb(); const parsed = PartyTools.CREATE_PARTY.inputSchema.parse(args); const now = new Date().toISOString(); const party: Party = { id: randomUUID(), name: parsed.name, description: parsed.description, worldId: parsed.worldId, status: 'active', formation: 'standard', createdAt: now, updatedAt: now, lastPlayedAt: now, }; partyRepo.create(party); // Add initial members if provided const addedMembers: { characterId: string; name: string; role: string }[] = []; let leaderId: string | null = null; if (parsed.initialMembers && parsed.initialMembers.length > 0) { for (let i = 0; i < parsed.initialMembers.length; i++) { const memberInput = parsed.initialMembers[i]; const character = charRepo.findById(memberInput.characterId); if (!character) { continue; // Skip invalid character IDs } const member: PartyMember = { id: randomUUID(), partyId: party.id, characterId: memberInput.characterId, role: memberInput.role || 'member', isActive: i === 0, // First member is active by default position: i + 1, sharePercentage: 100, joinedAt: now, }; partyRepo.addMember(member); addedMembers.push({ characterId: character.id, name: character.name, role: member.role, }); if (member.role === 'leader') { leaderId = character.id; } } } return { content: [{ type: 'text' as const, text: JSON.stringify({ party: { id: party.id, name: party.name, description: party.description, status: party.status, }, members: addedMembers, memberCount: addedMembers.length, leaderId, }, null, 2) }] }; } export async function handleGetParty(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.GET_PARTY.inputSchema.parse(args); const party = partyRepo.getPartyWithMembers(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } // Touch the party to update last_played_at partyRepo.touchParty(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify(party, null, 2) }] }; } export async function handleListParties(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.LIST_PARTIES.inputSchema.parse(args); const parties = partyRepo.findAll({ status: parsed.status, worldId: parsed.worldId, }); // Get member counts for each party const partiesWithCounts = parties.map(party => { const members = partyRepo.findMembersByParty(party.id); return { ...party, memberCount: members.length, }; }); return { content: [{ type: 'text' as const, text: JSON.stringify({ parties: partiesWithCounts, count: partiesWithCounts.length, }, null, 2) }] }; } export async function handleUpdateParty(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.UPDATE_PARTY.inputSchema.parse(args); const { partyId, ...updates } = parsed; const updated = partyRepo.update(partyId, updates); if (!updated) { throw new Error(`Party not found: ${partyId}`); } return { content: [{ type: 'text' as const, text: JSON.stringify(updated, null, 2) }] }; } export async function handleDeleteParty(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.DELETE_PARTY.inputSchema.parse(args); const deleted = partyRepo.delete(parsed.partyId); if (!deleted) { throw new Error(`Party not found: ${parsed.partyId}`); } return { content: [{ type: 'text' as const, text: JSON.stringify({ message: 'Party deleted', id: parsed.partyId, }, null, 2) }] }; } export async function handleAddPartyMember(args: unknown, _ctx: SessionContext) { const { partyRepo, charRepo } = ensureDb(); const parsed = PartyTools.ADD_PARTY_MEMBER.inputSchema.parse(args); // Verify party exists const party = partyRepo.findById(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } // Verify character exists const character = charRepo.findById(parsed.characterId); if (!character) { throw new Error(`Character not found: ${parsed.characterId}`); } // Check if already a member const existing = partyRepo.findMember(parsed.partyId, parsed.characterId); if (existing) { throw new Error(`Character ${character.name} is already in party ${party.name}`); } // If adding as leader, demote existing leader if (parsed.role === 'leader') { partyRepo.setLeader(parsed.partyId, parsed.characterId); } const now = new Date().toISOString(); const member: PartyMember = { id: randomUUID(), partyId: parsed.partyId, characterId: parsed.characterId, role: parsed.role || 'member', isActive: false, position: parsed.position, sharePercentage: 100, joinedAt: now, notes: parsed.notes, }; partyRepo.addMember(member); partyRepo.touchParty(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify({ message: `Added ${character.name} to ${party.name}`, member: { characterId: character.id, name: character.name, role: member.role, position: member.position, }, }, null, 2) }] }; } export async function handleRemovePartyMember(args: unknown, _ctx: SessionContext) { const { partyRepo, charRepo } = ensureDb(); const parsed = PartyTools.REMOVE_PARTY_MEMBER.inputSchema.parse(args); const character = charRepo.findById(parsed.characterId); const removed = partyRepo.removeMember(parsed.partyId, parsed.characterId); if (!removed) { throw new Error(`Member not found in party`); } return { content: [{ type: 'text' as const, text: JSON.stringify({ message: `Removed ${character?.name || parsed.characterId} from party`, characterId: parsed.characterId, }, null, 2) }] }; } export async function handleUpdatePartyMember(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.UPDATE_PARTY_MEMBER.inputSchema.parse(args); const { partyId, characterId, ...updates } = parsed; const updated = partyRepo.updateMember(partyId, characterId, updates); if (!updated) { throw new Error(`Member not found in party`); } return { content: [{ type: 'text' as const, text: JSON.stringify(updated, null, 2) }] }; } export async function handleSetPartyLeader(args: unknown, _ctx: SessionContext) { const { partyRepo, charRepo } = ensureDb(); const parsed = PartyTools.SET_PARTY_LEADER.inputSchema.parse(args); // Verify member exists const member = partyRepo.findMember(parsed.partyId, parsed.characterId); if (!member) { throw new Error(`Character is not a member of this party`); } const character = charRepo.findById(parsed.characterId); partyRepo.setLeader(parsed.partyId, parsed.characterId); partyRepo.touchParty(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify({ message: `${character?.name || parsed.characterId} is now the party leader`, leaderId: parsed.characterId, }, null, 2) }] }; } export async function handleSetActiveCharacter(args: unknown, _ctx: SessionContext) { const { partyRepo, charRepo } = ensureDb(); const parsed = PartyTools.SET_ACTIVE_CHARACTER.inputSchema.parse(args); // Verify member exists const member = partyRepo.findMember(parsed.partyId, parsed.characterId); if (!member) { throw new Error(`Character is not a member of this party`); } const character = charRepo.findById(parsed.characterId); partyRepo.setActiveCharacter(parsed.partyId, parsed.characterId); partyRepo.touchParty(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify({ message: `Active character set to ${character?.name || parsed.characterId}`, activeCharacterId: parsed.characterId, }, null, 2) }] }; } export async function handleGetPartyMembers(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.GET_PARTY_MEMBERS.inputSchema.parse(args); const party = partyRepo.getPartyWithMembers(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } return { content: [{ type: 'text' as const, text: JSON.stringify({ partyId: party.id, partyName: party.name, members: party.members, leader: party.leader, activeCharacter: party.activeCharacter, memberCount: party.memberCount, }, null, 2) }] }; } export async function handleGetPartyContext(args: unknown, _ctx: SessionContext) { const { partyRepo, questRepo } = ensureDb(); const parsed = PartyTools.GET_PARTY_CONTEXT.inputSchema.parse(args); const party = partyRepo.getPartyWithMembers(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } // Build context based on verbosity const context: PartyContext = { party: { id: party.id, name: party.name, status: party.status, location: party.currentLocation, formation: party.formation, }, members: party.members.map(m => ({ name: m.character.name, role: m.role, hp: `${m.character.hp}/${m.character.maxHp}`, status: m.character.hp < m.character.maxHp * 0.25 ? 'critical' : m.character.hp < m.character.maxHp * 0.5 ? 'wounded' : m.character.hp < m.character.maxHp ? 'hurt' : 'healthy', })), }; if (party.leader) { context.leader = { id: party.leader.character.id, name: party.leader.character.name, hp: party.leader.character.hp, maxHp: party.leader.character.maxHp, level: party.leader.character.level, }; } if (party.activeCharacter) { context.activeCharacter = { id: party.activeCharacter.character.id, name: party.activeCharacter.character.name, hp: party.activeCharacter.character.hp, maxHp: party.activeCharacter.character.maxHp, level: party.activeCharacter.character.level, conditions: party.activeCharacter.character.hp < party.activeCharacter.character.maxHp * 0.5 ? ['wounded'] : undefined, }; } // Add quest info if available if (party.currentQuestId) { try { const quest = questRepo.findById(party.currentQuestId); if (quest) { const completedCount = quest.objectives.filter((o: any) => o.completed).length; context.activeQuest = { name: quest.name, currentObjective: quest.objectives.find((o: any) => !o.completed)?.description, progress: `${Math.round((completedCount / quest.objectives.length) * 100)}%`, }; } } catch (e) { // Quest not found, skip } } partyRepo.touchParty(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify(context, null, 2) }] }; } export async function handleGetUnassignedCharacters(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.GET_UNASSIGNED_CHARACTERS.inputSchema.parse(args); const excludeTypes = parsed.excludeEnemies ? ['enemy'] : undefined; const characters = partyRepo.getUnassignedCharacters(excludeTypes); return { content: [{ type: 'text' as const, text: JSON.stringify({ characters, count: characters.length, }, null, 2) }] }; } // ========== Party Position & Movement Handlers ========== export async function handleMoveParty(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.MOVE_PARTY.inputSchema.parse(args); try { // Validate party exists const party = partyRepo.findById(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } // Update party position const updatedParty = partyRepo.updatePartyPosition( parsed.partyId, parsed.targetX, parsed.targetY, parsed.locationName, parsed.poiId ); if (!updatedParty) { throw new Error(`Failed to update party position: ${parsed.partyId}`); } return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, party: updatedParty, newPosition: { x: parsed.targetX, y: parsed.targetY, location: parsed.locationName, poiId: parsed.poiId || null, }, message: `Party "${updatedParty.name}" moved to ${parsed.locationName} (${parsed.targetX}, ${parsed.targetY})`, }, null, 2) }] }; } catch (error: any) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error.message || 'Failed to move party', }, null, 2) }] }; } } export async function handleGetPartyPosition(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.GET_PARTY_POSITION.inputSchema.parse(args); try { const party = partyRepo.findById(parsed.partyId); if (!party) { throw new Error(`Party not found: ${parsed.partyId}`); } const position = partyRepo.getPartyPosition(parsed.partyId); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, party: { id: party.id, name: party.name, }, position: position || { x: null, y: null, locationName: 'Unknown', poiId: null, }, }, null, 2) }] }; } catch (error: any) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error.message || 'Failed to get party position', }, null, 2) }] }; } } export async function handleGetPartiesInRegion(args: unknown, _ctx: SessionContext) { const { partyRepo } = ensureDb(); const parsed = PartyTools.GET_PARTIES_IN_REGION.inputSchema.parse(args); try { const parties = partyRepo.getPartiesNearPosition( parsed.worldId, parsed.x, parsed.y, parsed.radiusSquares ); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, count: parties.length, parties, message: `Found ${parties.length} parties within ${parsed.radiusSquares} squares of (${parsed.x}, ${parsed.y})`, }, null, 2) }] }; } catch (error: any) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: error.message || 'Failed to get parties in region', }, null, 2) }] }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server