unofficial-ravensburger-playhub-mcp
Version:
MCP server for finding Disney Lorcana TCG events
1,197 lines • 57.7 kB
JavaScript
/**
* MCP tools for event search, details, registrations, and tournament standings.
*/
import { z } from "zod";
import { expandStatusesForApi, fetchAllEventStandings, fetchEventDetails, fetchEventRegistrations, fetchEvents, fetchTournamentRoundMatches, fetchTournamentRoundStandings, geocodeAddress, resolveCategoryIdsStrict, resolveFormatIdsStrict, STATUSES, } from "../lib/api.js";
import { formatEvent, formatLeaderboard, formatMatchEntry, formatRegistrationEntry, formatStandingEntry, parseRecordToWinsLosses, } from "../lib/formatters.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const INVALID_DATE_ERROR = "Dates must be valid YYYY-MM-DD.";
function parseDateOnlyUtc(date) {
const match = DATE_ONLY_RE.exec(date);
if (!match)
return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day) {
return null;
}
return parsed;
}
function startOfTodayUtcIso() {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
}
function parseSearchDateRange(startDate, endDate) {
const start = startDate ? parseDateOnlyUtc(startDate) : null;
if (startDate && !start) {
return { startDateAfter: "", error: INVALID_DATE_ERROR };
}
const startDateAfter = start ? start.toISOString() : startOfTodayUtcIso();
if (!endDate) {
return { startDateAfter };
}
const end = parseDateOnlyUtc(endDate);
if (!end) {
return { startDateAfter: "", error: INVALID_DATE_ERROR };
}
if (start && start > end) {
return { startDateAfter: "", error: "start_date must be on or before end_date." };
}
// API expects an exclusive upper bound; add one day so end_date is inclusive for users.
const endExclusive = new Date(end.getTime() + DAY_MS);
return {
startDateAfter,
startDateBefore: endExclusive.toISOString(),
};
}
export function registerEventTools(server) {
// Tool: Search Events
server.registerTool("search_events", {
description: "Search for Disney Lorcana TCG events near a location by latitude/longitude. Use this when you have coordinates (e.g. from a map or device). For city names like 'Seattle' or 'Austin, TX', use search_events_by_city instead. Optional: call list_filters first to get format/category names for the formats and categories parameters.",
inputSchema: {
latitude: z.number().describe("Latitude of the search center (e.g. 42.33)"),
longitude: z.number().describe("Longitude of the search center (e.g. -83.05)"),
radius_miles: z.number().default(25).describe("Search radius in miles (default: 25)"),
start_date: z.string().optional().describe("Only show events starting on or after this date in UTC (YYYY-MM-DD)"),
end_date: z.string().optional().describe("Only show events starting on or before this date in UTC (YYYY-MM-DD, inclusive)"),
formats: z.array(z.string()).optional().describe("Filter by format names; get exact names from list_filters (e.g. ['Constructed'])"),
categories: z.array(z.string()).optional().describe("Filter by category names; get exact names from list_filters"),
statuses: z.array(z.enum(STATUSES)).default(["upcoming", "inProgress"]).describe("Include: upcoming, inProgress (live), past, or all (all three)"),
featured_only: z.boolean().default(false).describe("If true, only featured/headlining events"),
text_search: z.string().optional().describe("Search event names by keyword"),
store_id: z.number().optional().describe("Limit to events at this store (ID from search_stores)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
},
}, async (args) => {
const effectivePageSize = Math.min(args.page_size, 100);
const dateRange = parseSearchDateRange(args.start_date, args.end_date);
if (dateRange.error) {
return {
content: [{ type: "text", text: dateRange.error }],
isError: true,
};
}
let formatIds;
if (args.formats?.length) {
try {
formatIds = resolveFormatIdsStrict(args.formats);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
let categoryIds;
if (args.categories?.length) {
try {
categoryIds = resolveCategoryIdsStrict(args.categories);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
const params = {
game_slug: "disney-lorcana",
latitude: args.latitude.toString(),
longitude: args.longitude.toString(),
num_miles: args.radius_miles.toString(),
page: args.page.toString(),
page_size: effectivePageSize.toString(),
};
params.display_statuses = expandStatusesForApi(args.statuses);
params.start_date_after = dateRange.startDateAfter;
if (dateRange.startDateBefore)
params.start_date_before = dateRange.startDateBefore;
if (formatIds)
params.gameplay_format_id = formatIds;
if (categoryIds)
params.event_configuration_template_id = categoryIds;
if (args.featured_only) {
params.is_headlining_event = "true";
}
if (args.text_search) {
params.name = args.text_search;
}
if (args.store_id) {
params.store = args.store_id.toString();
}
try {
const response = await fetchEvents(params);
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: "No events found matching your criteria. Try expanding your search radius or adjusting filters.",
},
],
};
}
const formattedEvents = response.results.map(formatEvent).join("\n\n---\n\n");
const summary = `Found ${response.count} event(s). Showing ${response.results.length} (page ${args.page} of ${Math.ceil(response.count / effectivePageSize)}).`;
return {
content: [
{
type: "text",
text: `${summary}\n\n${formattedEvents}`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error searching events: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Event Details
server.registerTool("get_event_details", {
description: "Get full details for one Disney Lorcana event by ID. Use after search_events or search_events_by_city when the user asks for more info, results, or standings. For tournaments, the response includes tournament round IDs—use the latest completed round ID with get_tournament_round_standings to get current/final standings and results.",
inputSchema: {
event_id: z.number().describe("Event ID (e.g. from search results)"),
},
}, async (args) => {
try {
const event = await fetchEventDetails(args.event_id);
return {
content: [
{
type: "text",
text: formatEvent(event),
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching event details: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Tournament Round Standings
server.registerTool("get_tournament_round_standings", {
description: "Get standings (leaderboard) for a tournament round. Use when the user asks who is winning, standings, or results. Call get_event_details first for the event—the response lists round IDs; use the latest completed round ID (e.g. final Swiss round) for current/final standings.",
inputSchema: {
round_id: z.number().describe("Tournament round ID (e.g. 414976)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page (default: 25)"),
},
}, async (args) => {
try {
const response = await fetchTournamentRoundStandings(args.round_id, args.page, args.page_size);
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: `No standings found for round ${args.round_id}. The round may not exist or may not have standings yet.`,
},
],
};
}
const formatted = response.results.map((e, i) => formatStandingEntry(e, (args.page - 1) * args.page_size + i)).join("\n\n");
const summary = `Round ${args.round_id} standings: ${response.count} shown (page ${args.page} of ${Math.ceil(response.total / args.page_size)}). Total: ${response.total}.\n\n${formatted}`;
return {
content: [
{
type: "text",
text: summary,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching round standings: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Round Matches (pairings and results for a round)
server.registerTool("get_round_matches", {
description: "Get match pairings and results for a tournament round. Use when the user asks for pairings, match results, or who played whom. You need the round ID (from get_event_details—the response lists round IDs).",
inputSchema: {
round_id: z.number().describe("Tournament round ID (e.g. from get_event_details)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page (default: 25)"),
},
}, async (args) => {
try {
const response = await fetchTournamentRoundMatches(args.round_id, args.page, args.page_size);
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: `No matches found for round ${args.round_id}. The round may not exist or pairings may not be published yet.`,
},
],
};
}
const formatted = response.results
.map((e, i) => formatMatchEntry(e, (args.page - 1) * args.page_size + i))
.join("\n\n");
const summary = `Round ${args.round_id} matches: ${response.count} shown (page ${args.page} of ${Math.ceil(response.total / args.page_size)}). Total: ${response.total}.\n\n${formatted}`;
return {
content: [
{
type: "text",
text: summary,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching round matches: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Event Standings (tries completed rounds until one returns standings)
server.registerTool("get_event_standings", {
description: "Get tournament standings/results for an event by event ID. Use when the user asks for results, standings, or who won. This tool fetches the event's rounds and returns standings from the latest completed round that has data—so you don't need to look up round IDs. Prefer this over get_tournament_round_standings when the user asks for 'event results' or 'championship results'.",
inputSchema: {
event_id: z.number().describe("Event ID (from search_events, search_events_by_city, or get_event_details)"),
page_size: z.number().default(50).describe("Max standings to return from the round (default: 50)"),
},
}, async (args) => {
try {
const event = await fetchEventDetails(args.event_id);
const phases = event.tournament_phases;
if (!phases?.length) {
return {
content: [
{
type: "text",
text: `Event "${event.name}" (ID: ${args.event_id}) has no tournament rounds. Standings are only available for events with rounds.`,
},
],
};
}
const allRounds = [];
for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex++) {
const phase = phases[phaseIndex];
if (!phase.rounds?.length)
continue;
for (const r of phase.rounds) {
allRounds.push({
id: r.id,
round_number: r.round_number,
phase_name: phase.phase_name,
phase_index: phaseIndex,
});
}
}
// Prefer newest phase first, then highest round number within phase.
allRounds.sort((a, b) => b.phase_index - a.phase_index ||
b.round_number - a.round_number ||
b.id - a.id);
if (allRounds.length === 0) {
return {
content: [
{
type: "text",
text: `Event "${event.name}" (ID: ${args.event_id}) has no completed rounds yet. Standings will appear once rounds are finished.`,
},
],
};
}
for (const round of allRounds) {
try {
const response = await fetchTournamentRoundStandings(round.id, 1, args.page_size);
if (response.results.length > 0) {
const label = round.phase_name
? `Round ${round.round_number} (${round.phase_name})`
: `Round ${round.round_number}`;
const formatted = response.results
.map((e, i) => formatStandingEntry(e, i))
.join("\n\n");
const text = `${event.name} (ID: ${args.event_id}) — Standings for ${label} (round ID: ${round.id})\nTotal: ${response.total}\n\n${formatted}`;
return {
content: [{ type: "text", text }],
};
}
}
catch {
continue;
}
}
return {
content: [
{
type: "text",
text: `No standings available for event "${event.name}" (ID: ${args.event_id}). The API returned no standings for any of its ${allRounds.length} round(s). The event may still be in progress or standings may not be published yet. Round IDs tried: ${allRounds.map((r) => r.id).join(", ")}.`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching event standings: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Event Registrations
server.registerTool("get_event_registrations", {
description: "Get the list of players registered for an event. Use when the user asks who is signed up, the registration list, or how many spots are taken. You need the event ID (from search_events, search_events_by_city, or get_event_details).",
inputSchema: {
event_id: z.number().describe("Event ID (e.g. from search or get_event_details)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page (default: 25)"),
},
}, async (args) => {
try {
const response = await fetchEventRegistrations(args.event_id, args.page, args.page_size);
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: `No registrations found for event ${args.event_id}. The event may not exist or may not have registrations yet.`,
},
],
};
}
const formatted = response.results
.map((e, i) => formatRegistrationEntry(e, (args.page - 1) * args.page_size + i))
.join("\n\n");
const summary = `Event ${args.event_id} registrations: ${response.count} shown (page ${args.page} of ${Math.ceil(response.total / args.page_size)}). Total: ${response.total}.\n\n${formatted}`;
return {
content: [
{
type: "text",
text: summary,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching event registrations: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Search by City Name
server.registerTool("search_events_by_city", {
description: "Search for Disney Lorcana TCG events by city name (geocoded). Use this when the user says a city, e.g. 'events in Seattle' or 'Austin, TX'. For coordinates use search_events instead. Optional: call list_filters for format/category names.",
inputSchema: {
city: z.string().describe("City name, ideally with state/country (e.g. 'Detroit, MI' or 'New York, NY')"),
radius_miles: z.number().default(25).describe("Search radius in miles (default: 25)"),
start_date: z.string().optional().describe("Only show events starting on or after this date in UTC (YYYY-MM-DD)"),
end_date: z.string().optional().describe("Only show events starting on or before this date in UTC (YYYY-MM-DD, inclusive)"),
formats: z.array(z.string()).optional().describe("Filter by format names from list_filters (e.g. ['Constructed'])"),
categories: z.array(z.string()).optional().describe("Filter by category names from list_filters"),
statuses: z.array(z.enum(STATUSES)).default(["upcoming", "inProgress"]).describe("Include: upcoming, inProgress (live), past, or all (all three)"),
featured_only: z.boolean().default(false).describe("If true, only featured events"),
text_search: z.string().optional().describe("Search event names by keyword"),
store_id: z.number().optional().describe("Limit to events at this store (ID from search_stores)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
},
}, async (args) => {
const effectivePageSize = Math.min(args.page_size, 100);
try {
const dateRange = parseSearchDateRange(args.start_date, args.end_date);
if (dateRange.error) {
return {
content: [{ type: "text", text: dateRange.error }],
isError: true,
};
}
let formatIds;
if (args.formats?.length) {
try {
formatIds = resolveFormatIdsStrict(args.formats);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
let categoryIds;
if (args.categories?.length) {
try {
categoryIds = resolveCategoryIdsStrict(args.categories);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
const location = await geocodeAddress(args.city);
if (!location) {
return {
content: [
{
type: "text",
text: `Could not find location: ${args.city}. Try being more specific (e.g., "Detroit, MI, USA").`,
},
],
isError: true,
};
}
const latitude = location.address.lat;
const longitude = location.address.lng;
const params = {
game_slug: "disney-lorcana",
latitude: latitude.toString(),
longitude: longitude.toString(),
num_miles: args.radius_miles.toString(),
display_statuses: expandStatusesForApi(args.statuses),
page: args.page.toString(),
page_size: effectivePageSize.toString(),
start_date_after: dateRange.startDateAfter,
};
if (dateRange.startDateBefore)
params.start_date_before = dateRange.startDateBefore;
if (formatIds)
params.gameplay_format_id = formatIds;
if (categoryIds)
params.event_configuration_template_id = categoryIds;
if (args.featured_only) {
params.is_headlining_event = "true";
}
if (args.text_search) {
params.name = args.text_search;
}
if (args.store_id) {
params.store = args.store_id.toString();
}
const response = await fetchEvents(params);
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: `No events found near ${location.address.formattedAddress} within ${args.radius_miles} miles. Try expanding your search radius or adjusting filters.`,
},
],
};
}
const formattedEvents = response.results.map(formatEvent).join("\n\n---\n\n");
const summary = `Found ${response.count} event(s) near ${location.address.formattedAddress}. Showing ${response.results.length} (page ${args.page} of ${Math.ceil(response.count / effectivePageSize)}).`;
return {
content: [
{
type: "text",
text: `${summary}\n\n${formattedEvents}`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Store Events
server.registerTool("get_store_events", {
description: "Get events at a specific store by store ID. Use this after search_stores when the user asks about events at a particular store (e.g. 'events at Game Haven', 'what's coming up at Dragon's Lair'). This is simpler than search_events_by_city when you already have the store ID—no city name or geocoding needed.",
inputSchema: {
store_id: z.number().describe("Store ID (from search_stores)"),
start_date: z.string().optional().describe("Only show events starting on or after this date in UTC (YYYY-MM-DD)"),
end_date: z.string().optional().describe("Only show events starting on or before this date in UTC (YYYY-MM-DD, inclusive)"),
formats: z.array(z.string()).optional().describe("Filter by format names from list_filters (e.g. ['Constructed'])"),
categories: z.array(z.string()).optional().describe("Filter by category names from list_filters"),
statuses: z.array(z.enum(STATUSES)).default(["all"]).describe("Include: upcoming, inProgress (live), past, or all (all three)"),
page: z.number().default(1).describe("Page number (default: 1)"),
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
},
}, async (args) => {
try {
const effectivePageSize = Math.min(args.page_size, 100);
const dateRange = parseSearchDateRange(args.start_date, args.end_date);
if (dateRange.error) {
return {
content: [{ type: "text", text: dateRange.error }],
isError: true,
};
}
let formatIds;
if (args.formats?.length) {
try {
formatIds = resolveFormatIdsStrict(args.formats);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
let categoryIds;
if (args.categories?.length) {
try {
categoryIds = resolveCategoryIdsStrict(args.categories);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
// API requires lat/long + radius; use global center + Earth-covering radius
// so store= filter returns that store's events regardless of region
let storeName = `Store ${args.store_id}`;
const params = {
game_slug: "disney-lorcana",
latitude: "0",
longitude: "0",
num_miles: "12500", // ~half Earth circumference; covers globe for store-filtered queries
display_statuses: expandStatusesForApi(args.statuses),
store: args.store_id.toString(),
page: args.page.toString(),
page_size: effectivePageSize.toString(),
start_date_after: dateRange.startDateAfter,
};
if (dateRange.startDateBefore)
params.start_date_before = dateRange.startDateBefore;
if (formatIds)
params.gameplay_format_id = formatIds;
if (categoryIds)
params.event_configuration_template_id = categoryIds;
const response = await fetchEvents(params);
// Extract store name from the first event if available
if (response.results.length > 0 && response.results[0].store?.name) {
storeName = response.results[0].store.name;
}
if (response.results.length === 0) {
return {
content: [
{
type: "text",
text: `No events found at store ID ${args.store_id}. The store may not have any events matching your criteria, or the store ID may be invalid. Try adjusting date filters or statuses.`,
},
],
};
}
const formattedEvents = response.results.map(formatEvent).join("\n\n---\n\n");
const summary = `Found ${response.count} event(s) at ${storeName}. Showing ${response.results.length} (page ${args.page} of ${Math.ceil(response.count / effectivePageSize)}).`;
return {
content: [
{
type: "text",
text: `${summary}\n\n${formattedEvents}`,
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error fetching store events: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Constants for get_player_leaderboard
const MAX_RADIUS_MILES = 100;
const MAX_DATE_RANGE_DAYS = 366;
const MAX_LEADERBOARD_LIMIT = 100;
const SORT_OPTIONS = ["total_wins", "events_played", "win_rate", "best_placement"];
/**
* Stable key for aggregating a player across events.
* Uses player.id when available (most stable), otherwise falls back to player.best_identifier.
* Does NOT use user_event_status.best_identifier since that can vary per-event.
*/
function standingPlayerKey(entry) {
// Prefer player.id as the most stable identifier
if (entry.player?.id !== undefined && entry.player.id !== null) {
return `player_id:${entry.player.id}`;
}
// Fall back to player.best_identifier (first name + last initial, stable across events)
return (entry.player?.best_identifier ??
entry.player_name ??
entry.display_name ??
entry.username ??
"—");
}
/**
* Best display name for a player (for output/formatting).
* Prefers user_event_status.best_identifier (display name/username) when available.
*/
function standingPlayerDisplayName(entry) {
return (entry.user_event_status?.best_identifier ??
entry.player?.best_identifier ??
entry.player_name ??
entry.display_name ??
entry.username ??
"—");
}
function standingPlacement(entry, index) {
return entry.rank ?? entry.placement ?? index + 1;
}
/** Get wins and losses from entry; parse record string (e.g. "3-0-1") when numeric fields are missing. */
function standingWinsLosses(entry) {
if (entry.wins !== undefined && entry.wins !== null && entry.losses !== undefined && entry.losses !== null) {
return { wins: Number(entry.wins), losses: Number(entry.losses) };
}
return parseRecordToWinsLosses(entry.record ?? entry.match_record);
}
// Tool: Get Player Leaderboard (aggregate standings across events)
server.registerTool("get_player_leaderboard", {
description: "Aggregate player performance across multiple past and in-progress events and return a leaderboard. Use when the user asks who had the most wins, best record, or top performers in a region and date range (e.g. 'who had the most wins in set championships in January 2026 in Detroit'). Single tool call replaces many search_events + get_event_standings calls. Call list_filters first to get valid format/category names. Date range limited to 1 year; radius limited to 100 miles.",
inputSchema: {
city: z.string().min(1).describe("City name, ideally with state/country (e.g. 'Detroit, MI')"),
radius_miles: z.number().min(0).max(MAX_RADIUS_MILES).default(50).describe(`Search radius in miles (default: 50, max: ${MAX_RADIUS_MILES})`),
start_date: z.string().describe("Start of date range (YYYY-MM-DD)"),
end_date: z.string().describe("End of date range (YYYY-MM-DD); max 3 months from start_date"),
formats: z.array(z.string()).optional().describe("Filter by format names from list_filters"),
categories: z.array(z.string()).optional().describe("Filter by category names from list_filters (e.g. 'Set Championship')"),
sort_by: z.enum(SORT_OPTIONS).default("total_wins").describe("Sort order: total_wins, events_played, win_rate, best_placement"),
limit: z.number().min(1).max(MAX_LEADERBOARD_LIMIT).default(20).describe(`Number of top players to return (default: 20, max: ${MAX_LEADERBOARD_LIMIT})`),
min_events: z.number().min(1).default(1).describe("Minimum events a player must have played to appear (default: 1)"),
min_rounds: z.number().int().min(1).optional()
.describe("Minimum total tournament rounds an event must have to be included (e.g., 4 to exclude small leagues)"),
},
}, async (args) => {
try {
const cityTrimmed = args.city.trim();
if (!cityTrimmed) {
return {
content: [{ type: "text", text: "City cannot be empty." }],
isError: true,
};
}
const start = parseDateOnlyUtc(args.start_date);
const end = parseDateOnlyUtc(args.end_date);
if (!start || !end) {
return {
content: [{ type: "text", text: INVALID_DATE_ERROR }],
isError: true,
};
}
if (start > end) {
return {
content: [{ type: "text", text: "start_date must be on or before end_date." }],
isError: true,
};
}
const daysDiff = Math.floor((end.getTime() - start.getTime()) / DAY_MS);
if (daysDiff > MAX_DATE_RANGE_DAYS) {
return {
content: [{ type: "text", text: `Date range cannot exceed ${MAX_DATE_RANGE_DAYS} days (about 1 year).` }],
isError: true,
};
}
const endExclusive = new Date(end.getTime() + DAY_MS);
const radius = Math.min(MAX_RADIUS_MILES, Math.max(0, args.radius_miles));
const limit = Math.min(MAX_LEADERBOARD_LIMIT, Math.max(1, args.limit));
const minEvents = Math.max(1, args.min_events);
if (args.formats?.length) {
try {
resolveFormatIdsStrict(args.formats);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
if (args.categories?.length) {
try {
resolveCategoryIdsStrict(args.categories);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
const geocoded = await geocodeAddress(cityTrimmed);
if (!geocoded) {
return {
content: [{ type: "text", text: `Could not find location: ${cityTrimmed}. Try being more specific (e.g. "Detroit, MI, USA").` }],
isError: true,
};
}
const latitude = geocoded.address.lat;
const longitude = geocoded.address.lng;
const displayCity = geocoded.address.formattedAddress;
const params = {
game_slug: "disney-lorcana",
latitude: latitude.toString(),
longitude: longitude.toString(),
num_miles: radius.toString(),
display_statuses: ["past", "inProgress"],
start_date_after: start.toISOString(),
start_date_before: endExclusive.toISOString(),
page: "1",
page_size: "100",
};
if (args.formats?.length) {
params.gameplay_format_id = resolveFormatIdsStrict(args.formats);
}
if (args.categories?.length) {
params.event_configuration_template_id = resolveCategoryIdsStrict(args.categories);
}
const allEvents = [];
let page = 1;
let hasMore = true;
while (hasMore) {
params.page = page.toString();
const response = await fetchEvents(params);
for (const e of response.results) {
allEvents.push({ id: e.id, name: e.name, start_datetime: e.start_datetime });
}
hasMore = response.results.length === 100 && response.count > allEvents.length;
page += 1;
}
if (allEvents.length === 0) {
return {
content: [
{
type: "text",
text: `No past or in-progress events found near ${displayCity} for ${args.start_date} – ${args.end_date} with the given filters. Try a larger radius or different dates.`,
},
],
};
}
const eventStandings = await fetchAllEventStandings(allEvents.map((e) => e.id));
let filteredStandings = eventStandings;
if (args.min_rounds) {
filteredStandings = filteredStandings.filter(({ event }) => {
const totalRounds = (event.tournament_phases ?? []).reduce((sum, phase) => sum + (phase.rounds?.length ?? 0), 0);
return totalRounds >= args.min_rounds;
});
}
const agg = new Map();
for (const { event, standings } of filteredStandings) {
for (let i = 0; i < standings.length; i++) {
const entry = standings[i];
const key = standingPlayerKey(entry);
if (key === "—")
continue;
const displayName = standingPlayerDisplayName(entry);
const hasUserEventStatus = entry.user_event_status?.best_identifier !== undefined;
const placement = standingPlacement(entry, i);
const { wins, losses } = standingWinsLosses(entry);
let rec = agg.get(key);
if (!rec) {
rec = {
displayName,
hasUserEventStatus,
wins: 0,
losses: 0,
eventsPlayed: 0,
placements: [],
};
agg.set(key, rec);
}
else if (hasUserEventStatus && !rec.hasUserEventStatus) {
// Prefer display name from user_event_status when we find one
rec.displayName = displayName;
rec.hasUserEventStatus = true;
}
rec.wins += wins;
rec.losses += losses;
rec.eventsPlayed += 1;
rec.placements.push(placement);
}
}
let players = Array.from(agg.values())
.filter((r) => r.eventsPlayed >= minEvents)
.map((r) => ({
playerName: r.displayName,
totalWins: r.wins,
totalLosses: r.losses,
eventsPlayed: r.eventsPlayed,
bestPlacement: Math.min(...r.placements),
firstPlaceFinishes: r.placements.filter((p) => p === 1).length,
placements: r.placements,
}));
const sortBy = args.sort_by;
if (sortBy === "total_wins") {
players.sort((a, b) => {
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else if (sortBy === "events_played") {
players.sort((a, b) => {
if (b.eventsPlayed !== a.eventsPlayed)
return b.eventsPlayed - a.eventsPlayed;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else if (sortBy === "win_rate") {
players.sort((a, b) => {
const rateA = a.totalWins + a.totalLosses > 0 ? a.totalWins / (a.totalWins + a.totalLosses) : 0;
const rateB = b.totalWins + b.totalLosses > 0 ? b.totalWins / (b.totalWins + b.totalLosses) : 0;
if (rateB !== rateA)
return rateB - rateA;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else {
players.sort((a, b) => {
if (a.bestPlacement !== b.bestPlacement)
return a.bestPlacement - b.bestPlacement;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return 0;
});
}
players = players.slice(0, limit);
const sortLabels = {
total_wins: "TOTAL WINS",
events_played: "EVENTS PLAYED",
win_rate: "WIN RATE",
best_placement: "BEST PLACEMENT",
};
const result = {
players,
eventsAnalyzed: filteredStandings.length,
eventsIncluded: filteredStandings.map(({ event }) => ({
id: event.id,
name: event.name,
startDate: event.start_datetime,
})),
dateRange: { start: args.start_date, end: args.end_date },
filters: {
city: displayCity,
categories: args.categories?.length ? args.categories : undefined,
formats: args.formats?.length ? args.formats : undefined,
minRounds: args.min_rounds,
},
};
const text = formatLeaderboard(result, sortLabels[sortBy]);
return {
content: [{ type: "text", text }],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error building leaderboard: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Tool: Get Player Leaderboard by Store (aggregate standings at a specific store)
server.registerTool("get_player_leaderboard_by_store", {
description: "Aggregate player performance across past and in-progress events at a specific store and return a leaderboard. Use when the user asks who had the most wins or top performers at a particular store (e.g. 'leaderboard at Game Haven', 'best players at store 123'). Get store ID from search_stores. Date range limited to 1 year.",
inputSchema: {
store_id: z.number().describe("Store ID (from search_stores)"),
start_date: z.string().describe("Start of date range (YYYY-MM-DD)"),
end_date: z.string().describe("End of date range (YYYY-MM-DD); max 3 months from start_date"),
formats: z.array(z.string()).optional().describe("Filter by format names from list_filters"),
categories: z.array(z.string()).optional().describe("Filter by category names from list_filters (e.g. 'Set Championship')"),
sort_by: z.enum(SORT_OPTIONS).default("total_wins").describe("Sort order: total_wins, events_played, win_rate, best_placement"),
limit: z.number().min(1).max(MAX_LEADERBOARD_LIMIT).default(20).describe(`Number of top players to return (default: 20, max: ${MAX_LEADERBOARD_LIMIT})`),
min_events: z.number().min(1).default(1).describe("Minimum events a player must have played to appear (default: 1)"),
min_rounds: z.number().int().min(1).optional()
.describe("Minimum total tournament rounds an event must have to be included (e.g., 4 to exclude small leagues)"),
},
}, async (args) => {
try {
const start = parseDateOnlyUtc(args.start_date);
const end = parseDateOnlyUtc(args.end_date);
if (!start || !end) {
return {
content: [{ type: "text", text: INVALID_DATE_ERROR }],
isError: true,
};
}
if (start > end) {
return {
content: [{ type: "text", text: "start_date must be on or before end_date." }],
isError: true,
};
}
const daysDiff = Math.floor((end.getTime() - start.getTime()) / DAY_MS);
if (daysDiff > MAX_DATE_RANGE_DAYS) {
return {
content: [{ type: "text", text: `Date range cannot exceed ${MAX_DATE_RANGE_DAYS} days (about 1 year).` }],
isError: true,
};
}
const endExclusive = new Date(end.getTime() + DAY_MS);
const limit = Math.min(MAX_LEADERBOARD_LIMIT, Math.max(1, args.limit));
const minEvents = Math.max(1, args.min_events);
if (args.formats?.length) {
try {
resolveFormatIdsStrict(args.formats);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
if (args.categories?.length) {
try {
resolveCategoryIdsStrict(args.categories);
}
catch (e) {
return {
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
isError: true,
};
}
}
const params = {
game_slug: "disney-lorcana",
latitude: "0",
longitude: "0",
num_miles: "12500",
display_statuses: ["past", "inProgress"],
store: args.store_id.toString(),
start_date_after: start.toISOString(),
start_date_before: endExclusive.toISOString(),
page: "1",
page_size: "100",
};
if (args.formats?.length) {
params.gameplay_format_id = resolveFormatIdsStrict(args.formats);
}
if (args.categories?.length) {
params.event_configuration_template_id = resolveCategoryIdsStrict(args.categories);
}
const allEvents = [];
let page = 1;
let hasMore = true;
let storeName;
while (hasMore) {
params.page = page.toString();
const response = await fetchEvents(params);
for (const e of response.results) {
allEvents.push({ id: e.id, name: e.name, start_datetime: e.start_datetime });
if (!storeName && e.store?.name)
storeName = e.store.name;
}
hasMore = response.results.length === 100 && response.count > allEvents.length;
page += 1;
}
if (allEvents.length === 0) {
return {
content: [
{
type: "text",
text: `No past or in-progress events found at store ID ${args.store_id} for ${args.start_date} – ${args.end_date} with the given filters. Try different dates or filters.`,
},
],
};
}
const eventStandings = await fetchAllEventStandings(allEvents.map((e) => e.id));
let filteredStandings = eventStandings;
if (args.min_rounds) {
filteredStandings = filteredStandings.filter(({ event }) => {
const totalRounds = (event.tournament_phases ?? []).reduce((sum, phase) => sum + (phase.rounds?.length ?? 0), 0);
return totalRounds >= args.min_rounds;
});
}
const agg = new Map();
for (const { event, standings } of filteredStandings) {
for (let i = 0; i < standings.length; i++) {
const entry = standings[i];
const key = standingPlayerKey(entry);
if (key === "—")
continue;
const displayName = standingPlayerDisplayName(entry);
const hasUserEventStatus = entry.user_event_status?.best_identifier !== undefined;
const placement = standingPlacement(entry, i);
const { wins, losses } = standingWinsLosses(entry);
let rec = agg.get(key);
if (!rec) {
rec = {
displayName,
hasUserEventStatus,
wins: 0,
losses: 0,
eventsPlayed: 0,
placements: [],
};
agg.set(key, rec);
}
else if (hasUserEventStatus && !rec.hasUserEventStatus) {
// Prefer display name from user_event_status when we find one
rec.displayName = displayName;
rec.hasUserEventStatus = true;
}
rec.wins += wins;
rec.losses += losses;
rec.eventsPlayed += 1;
rec.placements.push(placement);
}
}
let players = Array.from(agg.values())
.filter((r) => r.eventsPlayed >= minEvents)
.map((r) => ({
playerName: r.displayName,
totalWins: r.wins,
totalLosses: r.losses,
eventsPlayed: r.eventsPlayed,
bestPlacement: Math.min(...r.placements),
firstPlaceFinishes: r.placements.filter((p) => p === 1).length,
placements: r.placements,
}));
const sortBy = args.sort_by;
if (sortBy === "total_wins") {
players.sort((a, b) => {
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else if (sortBy === "events_played") {
players.sort((a, b) => {
if (b.eventsPlayed !== a.eventsPlayed)
return b.eventsPlayed - a.eventsPlayed;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else if (sortBy === "win_rate") {
players.sort((a, b) => {
const rateA = a.totalWins + a.totalLosses > 0 ? a.totalWins / (a.totalWins + a.totalLosses) : 0;
const rateB = b.totalWins + b.totalLosses > 0 ? b.totalWins / (b.totalWins + b.totalLosses) : 0;
if (rateB !== rateA)
return rateB - rateA;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return a.bestPlacement - b.bestPlacement;
});
}
else {
players.sort((a, b) => {
if (a.bestPlacement !== b.bestPlacement)
return a.bestPlacement - b.bestPlacement;
if (b.totalWins !== a.totalWins)
return b.totalWins - a.totalWins;
if (a.totalLosses !== b.totalLosses)
return a.totalLosses - b.totalLosses;
return 0;
});
}
players = players.slice(0, limit);
const sortLabels = {
total_wins: "TOTAL WINS",
events_played: "EVENTS PLAYED",
win_rate: "WIN RATE",
best_placement: "BEST PLACEMENT",
};
const result = {
players,
eventsAnalyzed: filteredStandings.length,
eventsIncluded: filteredStandings.map(({ event }) => ({
id: event.id,
name: event.name,
startDate: event.start_datetime,
})),
dateRange: { start: args.start_date, end: args.end_date },
filters: {
store: storeName ?? `Store ${args.store_id}`,
categories: args.categories?.length ? args.categories : undefined,
formats: args.formats?.length ? args.formats : undefined,
minRounds: args.min_rounds,
},
};
const text = formatLeaderboard(result, sortLabels[sortBy]);
return {
content: [{ type: "text", text }],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error building leaderboard: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
}