Skip to main content
Glama
party.repo.ts17 kB
import Database from 'better-sqlite3'; import { Party, PartySchema, PartyMember, PartyMemberSchema, PartyWithMembers, PartyMemberWithCharacter, MemberRole, PartyStatus } from '../../schema/party.js'; interface PartyRow { id: string; name: string; description: string | null; world_id: string | null; status: string; current_location: string | null; current_quest_id: string | null; formation: string; created_at: string; updated_at: string; last_played_at: string | null; position_x: number | null; position_y: number | null; current_poi: string | null; } interface PartyMemberRow { id: string; party_id: string; character_id: string; role: string; is_active: number; position: number | null; share_percentage: number; joined_at: string; notes: string | null; } // Row returned from the join query with character data interface PartyMemberWithCharacterRow extends PartyMemberRow { char_id: string; char_name: string; stats: string; hp: number; max_hp: number; ac: number; level: number; behavior: string | null; character_type: string | null; race: string | null; character_class: string | null; } export class PartyRepository { constructor(private db: Database.Database) {} // ========== Party CRUD ========== create(party: Party): Party { const validated = PartySchema.parse(party); const stmt = this.db.prepare(` INSERT INTO parties (id, name, description, world_id, status, current_location, current_quest_id, formation, position_x, position_y, current_poi, created_at, updated_at, last_played_at) VALUES (@id, @name, @description, @worldId, @status, @currentLocation, @currentQuestId, @formation, @positionX, @positionY, @currentPOI, @createdAt, @updatedAt, @lastPlayedAt) `); stmt.run({ id: validated.id, name: validated.name, description: validated.description || null, worldId: validated.worldId || null, status: validated.status, currentLocation: validated.currentLocation || null, currentQuestId: validated.currentQuestId || null, formation: validated.formation, positionX: validated.positionX ?? null, positionY: validated.positionY ?? null, currentPOI: validated.currentPOI || null, createdAt: validated.createdAt, updatedAt: validated.updatedAt, lastPlayedAt: validated.lastPlayedAt || null, }); return validated; } findById(id: string): Party | null { const stmt = this.db.prepare('SELECT * FROM parties WHERE id = ?'); const row = stmt.get(id) as PartyRow | undefined; if (!row) return null; return this.rowToParty(row); } findAll(filters?: { status?: PartyStatus; worldId?: string }): Party[] { let query = 'SELECT * FROM parties WHERE 1=1'; const params: any[] = []; if (filters?.status) { query += ' AND status = ?'; params.push(filters.status); } if (filters?.worldId) { query += ' AND world_id = ?'; params.push(filters.worldId); } query += ' ORDER BY last_played_at DESC NULLS LAST, updated_at DESC'; const stmt = this.db.prepare(query); const rows = stmt.all(...params) as PartyRow[]; return rows.map(row => this.rowToParty(row)); } update(id: string, updates: Partial<Party>): Party | null { const existing = this.findById(id); if (!existing) return null; const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; const validated = PartySchema.parse(updated); const stmt = this.db.prepare(` UPDATE parties SET name = ?, description = ?, world_id = ?, status = ?, current_location = ?, current_quest_id = ?, formation = ?, position_x = ?, position_y = ?, current_poi = ?, updated_at = ?, last_played_at = ? WHERE id = ? `); stmt.run( validated.name, validated.description || null, validated.worldId || null, validated.status, validated.currentLocation || null, validated.currentQuestId || null, validated.formation, validated.positionX ?? null, validated.positionY ?? null, validated.currentPOI || null, validated.updatedAt, validated.lastPlayedAt || null, id ); return validated; } delete(id: string): boolean { const stmt = this.db.prepare('DELETE FROM parties WHERE id = ?'); const result = stmt.run(id); return result.changes > 0; } // ========== Party Members ========== addMember(member: PartyMember): PartyMember { const validated = PartyMemberSchema.parse(member); const stmt = this.db.prepare(` INSERT INTO party_members (id, party_id, character_id, role, is_active, position, share_percentage, joined_at, notes) VALUES (@id, @partyId, @characterId, @role, @isActive, @position, @sharePercentage, @joinedAt, @notes) `); stmt.run({ id: validated.id, partyId: validated.partyId, characterId: validated.characterId, role: validated.role, isActive: validated.isActive ? 1 : 0, position: validated.position ?? null, sharePercentage: validated.sharePercentage, joinedAt: validated.joinedAt, notes: validated.notes || null, }); return validated; } removeMember(partyId: string, characterId: string): boolean { const stmt = this.db.prepare( 'DELETE FROM party_members WHERE party_id = ? AND character_id = ?' ); const result = stmt.run(partyId, characterId); return result.changes > 0; } updateMember(partyId: string, characterId: string, updates: Partial<PartyMember>): PartyMember | null { const existing = this.findMember(partyId, characterId); if (!existing) return null; const updated = { ...existing, ...updates, }; const stmt = this.db.prepare(` UPDATE party_members SET role = ?, is_active = ?, position = ?, share_percentage = ?, notes = ? WHERE party_id = ? AND character_id = ? `); stmt.run( updated.role, updated.isActive ? 1 : 0, updated.position ?? null, updated.sharePercentage, updated.notes || null, partyId, characterId ); return updated; } findMember(partyId: string, characterId: string): PartyMember | null { const stmt = this.db.prepare( 'SELECT * FROM party_members WHERE party_id = ? AND character_id = ?' ); const row = stmt.get(partyId, characterId) as PartyMemberRow | undefined; if (!row) return null; return this.rowToMember(row); } findMembersByParty(partyId: string): PartyMember[] { const stmt = this.db.prepare( 'SELECT * FROM party_members WHERE party_id = ? ORDER BY position ASC NULLS LAST, joined_at ASC' ); const rows = stmt.all(partyId) as PartyMemberRow[]; return rows.map(row => this.rowToMember(row)); } findPartiesByCharacter(characterId: string): Party[] { const stmt = this.db.prepare(` SELECT p.* FROM parties p INNER JOIN party_members pm ON p.id = pm.party_id WHERE pm.character_id = ? ORDER BY p.last_played_at DESC NULLS LAST `); const rows = stmt.all(characterId) as PartyRow[]; return rows.map(row => this.rowToParty(row)); } // ========== Complex Queries ========== setLeader(partyId: string, characterId: string): boolean { // First, demote any existing leader to member this.db.prepare(` UPDATE party_members SET role = 'member' WHERE party_id = ? AND role = 'leader' `).run(partyId); // Promote new leader const stmt = this.db.prepare(` UPDATE party_members SET role = 'leader' WHERE party_id = ? AND character_id = ? `); const result = stmt.run(partyId, characterId); return result.changes > 0; } setActiveCharacter(partyId: string, characterId: string): boolean { // First, clear any existing active character this.db.prepare(` UPDATE party_members SET is_active = 0 WHERE party_id = ? AND is_active = 1 `).run(partyId); // Set new active character const stmt = this.db.prepare(` UPDATE party_members SET is_active = 1 WHERE party_id = ? AND character_id = ? `); const result = stmt.run(partyId, characterId); return result.changes > 0; } getPartyWithMembers(partyId: string): PartyWithMembers | null { const party = this.findById(partyId); if (!party) return null; // Get all members with their character data const stmt = this.db.prepare(` SELECT pm.id, pm.party_id, pm.character_id, pm.role, pm.is_active, pm.position, pm.share_percentage, pm.joined_at, pm.notes, c.id as char_id, c.name as char_name, c.stats, c.hp, c.max_hp, c.ac, c.level, c.behavior, c.character_type, c.race, c.character_class FROM party_members pm INNER JOIN characters c ON pm.character_id = c.id WHERE pm.party_id = ? ORDER BY CASE pm.role WHEN 'leader' THEN 0 ELSE 1 END, pm.position ASC NULLS LAST, pm.joined_at ASC `); const rows = stmt.all(partyId) as PartyMemberWithCharacterRow[]; const members: PartyMemberWithCharacter[] = rows.map(row => ({ id: row.id, partyId: row.party_id, characterId: row.character_id, role: row.role as MemberRole, isActive: row.is_active === 1, position: row.position ?? undefined, sharePercentage: row.share_percentage, joinedAt: row.joined_at, notes: row.notes ?? undefined, character: { id: row.char_id, name: row.char_name, hp: row.hp, maxHp: row.max_hp, ac: row.ac, level: row.level, stats: JSON.parse(row.stats), behavior: row.behavior ?? undefined, characterType: (row.character_type as any) ?? undefined, race: row.race ?? undefined, class: row.character_class ?? undefined, } })); const leader = members.find(m => m.role === 'leader'); const activeCharacter = members.find(m => m.isActive); return { ...party, members, leader, activeCharacter, memberCount: members.length, }; } getUnassignedCharacters(excludeTypes?: string[]): { id: string; name: string; level: number; characterType: string | null; race: string | null; class: string | null }[] { let query = ` SELECT c.id, c.name, c.level, c.character_type as characterType, c.race, c.character_class as class FROM characters c LEFT JOIN party_members pm ON c.id = pm.character_id WHERE pm.id IS NULL `; const params: any[] = []; if (excludeTypes && excludeTypes.length > 0) { query += ` AND (c.character_type IS NULL OR c.character_type NOT IN (${excludeTypes.map(() => '?').join(', ')}))`; params.push(...excludeTypes); } query += ' ORDER BY c.name ASC'; const stmt = this.db.prepare(query); return stmt.all(...params) as { id: string; name: string; level: number; characterType: string | null; race: string | null; class: string | null }[]; } // ========== Touch for activity tracking ========== touchParty(partyId: string): void { const now = new Date().toISOString(); this.db.prepare(` UPDATE parties SET last_played_at = ?, updated_at = ? WHERE id = ? `).run(now, now, partyId); } // ========== Party Position Management ========== updatePartyPosition( partyId: string, x: number, y: number, locationName: string, poiId?: string ): Party | null { const stmt = this.db.prepare(` UPDATE parties SET position_x = ?, position_y = ?, current_location = ?, current_poi = ?, updated_at = ? WHERE id = ? RETURNING * `); const result = stmt.get(x, y, locationName, poiId || null, new Date().toISOString(), partyId) as PartyRow | undefined; if (!result) { throw new Error(`Party not found: ${partyId}`); } return this.rowToParty(result); } getPartyPosition(partyId: string): { x: number; y: number; locationName: string; poiId?: string } | null { const stmt = this.db.prepare(` SELECT position_x, position_y, current_location, current_poi FROM parties WHERE id = ? `); const result = stmt.get(partyId) as any; if (!result || result.position_x === null) { return null; } return { x: result.position_x, y: result.position_y, locationName: result.current_location || 'Unknown Location', poiId: result.current_poi || undefined, }; } getPartiesWithPositions(worldId: string): Array<Party & { position: { x: number; y: number; locationName: string; poiId?: string } }> { const stmt = this.db.prepare(` SELECT * FROM parties WHERE world_id = ? AND position_x IS NOT NULL ORDER BY updated_at DESC `); const results = stmt.all(worldId) as PartyRow[]; return results.map((row) => ({ ...this.rowToParty(row), position: { x: row.position_x || 0, y: row.position_y || 0, locationName: row.current_location || 'Unknown Location', poiId: row.current_poi || undefined, }, })); } getPartiesNearPosition( worldId: string, x: number, y: number, radiusSquares: number = 3 ): Party[] { const stmt = this.db.prepare(` SELECT * FROM parties WHERE world_id = ? AND position_x IS NOT NULL AND ABS(position_x - ?) <= ? AND ABS(position_y - ?) <= ? ORDER BY (position_x - ?) * (position_x - ?) + (position_y - ?) * (position_y - ?) `); const results = stmt.all(worldId, x, radiusSquares, y, radiusSquares, x, x, y, y) as PartyRow[]; return results.map(row => this.rowToParty(row)); } // ========== Row converters ========== private rowToParty(row: PartyRow): Party { return PartySchema.parse({ id: row.id, name: row.name, description: row.description ?? undefined, worldId: row.world_id ?? undefined, status: row.status, currentLocation: row.current_location ?? undefined, currentQuestId: row.current_quest_id ?? undefined, formation: row.formation, positionX: (row as any).position_x ?? undefined, positionY: (row as any).position_y ?? undefined, currentPOI: (row as any).current_poi ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, lastPlayedAt: row.last_played_at ?? undefined, }); } private rowToMember(row: PartyMemberRow): PartyMember { return PartyMemberSchema.parse({ id: row.id, partyId: row.party_id, characterId: row.character_id, role: row.role, isActive: row.is_active === 1, position: row.position ?? undefined, sharePercentage: row.share_percentage, joinedAt: row.joined_at, notes: row.notes ?? undefined, }); } }

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