unofficial-ravensburger-playhub-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Lorcana Event Finder (MCP Server)
2
+
3
+ An **MCP (Model Context Protocol) server** that lets AI assistants look up **Disney Lorcana TCG** events, stores, and tournament data. It talks to the official Ravensburger Play API so you can ask things like “Where can I play Lorcana near Seattle?” or “What events are coming up in Austin?” from Cursor, Claude Desktop, Claude Code, or any MCP client.
4
+
5
+ ## What you need
6
+
7
+ - **Node.js** 18+ (for `node --test` and ESM)
8
+ - **npm** (comes with Node)
9
+
10
+ No API keys or configuration are required. Event and store search work out of the box.
11
+
12
+ ## Quick start
13
+
14
+ **From npm (no clone):**
15
+
16
+ ```bash
17
+ npx -y unofficial-ravensburger-playhub-mcp
18
+ ```
19
+
20
+ **From source:**
21
+
22
+ ```bash
23
+ git clone https://github.com/zammitt/unofficial-ravensburger-playhub-mcp.git
24
+ cd unofficial-ravensburger-playhub-mcp
25
+ npm install
26
+ npm run build
27
+ npm start
28
+ ```
29
+
30
+ The server runs over stdio. Add it as an MCP server in your client (e.g. Cursor) to use the tools.
31
+
32
+ ## Using with Cursor
33
+
34
+ **Option A — npx (easiest):** No clone or build. In **Settings → MCP**, add:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "unofficial-ravensburger-playhub-mcp": {
40
+ "command": "npx",
41
+ "args": ["-y", "unofficial-ravensburger-playhub-mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ **Option B — from a local clone:** Build first (`npm run build`), then point at the built script:
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "unofficial-ravensburger-playhub-mcp": {
53
+ "command": "node",
54
+ "args": ["/path/to/unofficial-ravensburger-playhub-mcp/dist/index.js"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ After that, the Lorcana Event Finder tools are available to the AI in Cursor.
61
+
62
+ ## Using with Claude
63
+
64
+ Add the server to **Claude Desktop** by editing your MCP config file:
65
+
66
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
67
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
68
+ - **Linux:** `~/.config/Claude/claude_desktop_config.json`
69
+
70
+ **Option A — npx (easiest):** No clone or build. Add or merge into the `mcpServers` object:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "unofficial-ravensburger-playhub-mcp": {
76
+ "command": "npx",
77
+ "args": ["-y", "unofficial-ravensburger-playhub-mcp"]
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ **Option B — from a local clone:** Build first (`npm run build`), then point at the built script:
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "unofficial-ravensburger-playhub-mcp": {
89
+ "command": "node",
90
+ "args": ["/path/to/unofficial-ravensburger-playhub-mcp/dist/index.js"]
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Restart Claude Desktop after changing the config. The Lorcana Event Finder tools will then be available to Claude.
97
+
98
+ ## Using with Claude Code
99
+
100
+ **Claude Code** (the IDE) can use this server via the CLI or by editing config. Options must come *before* the server name; `--` separates the name from the command.
101
+
102
+ **Option A — CLI with npx (easiest):** No clone or build. Run in your project or from any directory:
103
+
104
+ ```bash
105
+ claude mcp add --transport stdio unofficial-ravensburger-playhub-mcp -- npx -y unofficial-ravensburger-playhub-mcp
106
+ ```
107
+
108
+ Use `--scope user` to make it available in all projects:
109
+
110
+ ```bash
111
+ claude mcp add --transport stdio --scope user unofficial-ravensburger-playhub-mcp -- npx -y unofficial-ravensburger-playhub-mcp
112
+ ```
113
+
114
+ **Option B — CLI from a local clone:** Build first (`npm run build`), then:
115
+
116
+ ```bash
117
+ claude mcp add --transport stdio unofficial-ravensburger-playhub-mcp -- node /path/to/unofficial-ravensburger-playhub-mcp/dist/index.js
118
+ ```
119
+
120
+ **Option C — Config file:** Add the same `mcpServers` entry as in the Cursor/Claude Desktop sections to:
121
+
122
+ - **Project scope:** `.mcp.json` in your project root (share with the team), or
123
+ - **User scope:** `~/.claude.json` (in the `mcpServers` object).
124
+
125
+ Example for `.mcp.json` or `~/.claude.json`:
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "unofficial-ravensburger-playhub-mcp": {
131
+ "command": "npx",
132
+ "args": ["-y", "unofficial-ravensburger-playhub-mcp"]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ **Windows (native, not WSL):** For npx-based servers, use the `cmd /c` wrapper:
139
+
140
+ ```bash
141
+ claude mcp add --transport stdio unofficial-ravensburger-playhub-mcp -- cmd /c npx -y unofficial-ravensburger-playhub-mcp
142
+ ```
143
+
144
+ Check that the server is listed with `claude mcp list`; in Claude Code you can run `/mcp` to see status.
145
+
146
+ ### LLM-friendly design
147
+
148
+ Tool descriptions include **when to use** each tool (e.g. city name → `search_events_by_city`, coordinates → `search_events`). Call **list_capabilities** first if the assistant is unsure which tool to use. **list_filters** returns exact format/category names for event search parameters.
149
+
150
+ ## MCP tools
151
+
152
+ The server exposes tools that are easy for LLMs to choose and call: descriptions include **when to use** each tool, and optional parameters are clearly documented. Call **list_capabilities** first if unsure which tool to use.
153
+
154
+ | Tool | When to use |
155
+ |------|-------------|
156
+ | **list_capabilities** | Call first when unsure which tool to use (e.g. search_events vs search_events_by_city). Returns a short guide. |
157
+ | **list_filters** | Before searching events by format or category; returns exact names for the `formats` and `categories` parameters. |
158
+ | **search_events** | When you have latitude/longitude (e.g. from a map or device). |
159
+ | **search_events_by_city** | When the user says a city name (e.g. "events in Seattle" or "Austin, TX"). Geocoded. |
160
+ | **get_event_details** | Full details for one event; use when you have an event ID (e.g. from a search). |
161
+ | **get_event_registrations** | Who is signed up for an event; needs event ID. |
162
+ | **get_tournament_round_standings** | Standings/leaderboard for a tournament round; needs round ID. |
163
+ | **search_stores** | Stores or venues; optional name search and/or lat/long + radius. |
164
+ | **search_stores_by_city** | Stores near a city name (e.g. "stores in Seattle"). |
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ npm install
170
+ npm run build # compile TypeScript to dist/
171
+ npm test # run all tests from dist/ (unit + integration); run build first
172
+ npm run test:coverage # run tests with coverage report (c8)
173
+ ```
174
+
175
+ - **`npm run dev`** – Run the server with `tsx` (no build step) for quick iteration.
176
+ - **`npm start`** – Run the compiled server: `node dist/index.js`.
177
+
178
+ ### Project structure
179
+
180
+ | Path | Purpose |
181
+ |------|---------|
182
+ | **`src/index.ts`** | MCP server entry point: create server, register tools, run stdio transport. |
183
+ | **`src/lib/`** | Core library: `types.ts`, `api.ts`, `formatters.ts` (Ravensburger API client, types, human-readable formatters). |
184
+ | **`src/tools/`** | MCP tool handlers: `events.ts`, `stores.ts`, `filters.ts`. |
185
+ | **`src/test/`** | Unit tests (api, formatters) and integration tests (MCP server + tools). |
186
+
187
+ ### Tests
188
+
189
+ - **Unit tests** – `api.test.ts` (API client: filter maps, fetch with mocked `fetch`, `loadFilterOptions`), `formatters.test.ts` (formatStore, formatEvent, formatStandingEntry, formatRegistrationEntry), `registrations.test.ts`, `standings.test.ts`. No network required for unit tests.
190
+ - **Integration tests** – `mcp-tools.integration.test.ts` spawns the MCP server and calls each tool (required-only, optional params, pagination). They hit the real Ravensburger Play API and Nominatim for geocoding, so **network access is required** and tests may be slower or flaky if the APIs are slow or down.
191
+
192
+ Coverage is reported for `dist/` (excluding `dist/test/`). Run `npm run test:coverage` to see statement/branch/function coverage for the app code.
193
+
194
+ ## API and data
195
+
196
+ Data comes from the **Ravensburger Play API** (events, stores, formats, categories, registrations, tournament rounds/standings). City-based search uses **Nominatim** (OpenStreetMap) for geocoding. Neither API requires keys.
197
+
198
+ ## Security & privacy
199
+
200
+ - No API keys or secrets are stored in this repo or by the server.
201
+ - All tools use public Ravensburger and Nominatim endpoints with no authentication.
202
+
203
+ ## Publishing to npm
204
+
205
+ 1. **Create an npm account** (if needed): [npmjs.com/signup](https://www.npmjs.com/signup).
206
+ 2. **Enable 2FA (required to publish):** npm requires two-factor authentication to publish. In [Account Settings → Two-Factor Authentication](https://www.npmjs.com/settings/~yourusername/account), turn on 2FA (auth-only or auth-and-writes). You’ll be prompted for the code when you run `npm publish`.
207
+ 3. **Log in from the CLI:** `npm login` (username, password, OTP when prompted).
208
+ 4. **Check the package name:** Ensure `unofficial-ravensburger-playhub-mcp` is not taken: [npmjs.com/package/unofficial-ravensburger-playhub-mcp](https://www.npmjs.com/package/unofficial-ravensburger-playhub-mcp). If it is taken, change `name` in `package.json` (and optionally scope it, e.g. `@yourusername/unofficial-ravensburger-playhub-mcp`).
209
+ 5. **Dry run:** `npm publish --dry-run` to see what would be uploaded (no publish).
210
+ 6. **Publish:** `npm publish`. You’ll be prompted for your 2FA code. The `prepublishOnly` script runs `npm run build` first, so `dist/` is built automatically.
211
+ 7. **Later releases:** Bump `version` in `package.json` (e.g. `1.0.1`), then run `npm publish` again.
212
+
213
+ ## License
214
+
215
+ [Unlicense](https://unlicense.org) — public domain. No copyright, no warranty, no liability. Use at your own risk.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { loadFilterOptions } from "./lib/api.js";
7
+ import { registerEventTools } from "./tools/events.js";
8
+ import { registerFilterTools } from "./tools/filters.js";
9
+ import { registerStoreTools } from "./tools/stores.js";
10
+ const isEntryModule = process.argv[1] != null &&
11
+ resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
12
+ const server = new McpServer({
13
+ name: "lorcana-event-finder",
14
+ version: "1.0.0",
15
+ });
16
+ registerEventTools(server);
17
+ registerStoreTools(server);
18
+ registerFilterTools(server);
19
+ async function main() {
20
+ await loadFilterOptions();
21
+ const transport = new StdioServerTransport();
22
+ await server.connect(transport);
23
+ console.error("Lorcana Event Finder MCP server running on stdio");
24
+ }
25
+ if (isEntryModule) {
26
+ main().catch((error) => {
27
+ console.error("Fatal error:", error);
28
+ process.exit(1);
29
+ });
30
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * API client for Ravensburger Play (events, stores, formats, categories, standings, registrations).
3
+ */
4
+ import type { Event, EventCategory, EventsResponse, GameplayFormat, RegistrationsResponse, StandingsResponse, StoresResponse } from "./types.js";
5
+ /** Event statuses supported by the API. */
6
+ export declare const STATUSES: readonly ["upcoming", "inProgress", "past"];
7
+ /** Load and cache formats and categories from the API (called at server startup). */
8
+ export declare function loadFilterOptions(): Promise<void>;
9
+ /** Update the in-memory format/category maps (e.g. after list_filters refresh). */
10
+ export declare function updateFilterMaps(formats: GameplayFormat[], categories: EventCategory[]): void;
11
+ export declare function fetchGameplayFormats(): Promise<GameplayFormat[]>;
12
+ export declare function fetchCategories(): Promise<EventCategory[]>;
13
+ export declare function fetchEvents(params: Record<string, string | string[]>): Promise<EventsResponse>;
14
+ export declare function fetchEventDetails(eventId: number): Promise<Event>;
15
+ export declare function fetchEventRegistrations(eventId: number, page?: number, pageSize?: number): Promise<RegistrationsResponse>;
16
+ export declare function fetchTournamentRoundStandings(roundId: number, page?: number, pageSize?: number): Promise<StandingsResponse>;
17
+ export declare function fetchStores(params: Record<string, string>): Promise<StoresResponse>;
18
+ /** Resolve format display names to API IDs. */
19
+ export declare function resolveFormatIds(formatNames: string[]): string[];
20
+ /** Resolve category display names to API IDs. */
21
+ export declare function resolveCategoryIds(categoryNames: string[]): string[];
22
+ /** Reverse lookup: category template ID to display name. */
23
+ export declare function getCategoryName(templateId: string): string;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * API client for Ravensburger Play (events, stores, formats, categories, standings, registrations).
3
+ */
4
+ const API_BASE = "https://api.cloudflare.ravensburgerplay.com/hydraproxy/api/v2";
5
+ /** Event statuses supported by the API. */
6
+ export const STATUSES = ["upcoming", "inProgress", "past"];
7
+ // Dynamic lookup maps - populated at startup and refreshed by list_filters
8
+ let FORMAT_MAP = new Map(); // name -> id
9
+ let CATEGORY_MAP = new Map(); // name -> id
10
+ /** Load and cache formats and categories from the API (called at server startup). */
11
+ export async function loadFilterOptions() {
12
+ try {
13
+ const [formats, categories] = await Promise.all([
14
+ fetchGameplayFormats(),
15
+ fetchCategories(),
16
+ ]);
17
+ updateFilterMaps(formats, categories);
18
+ console.error(`Loaded ${FORMAT_MAP.size} formats and ${CATEGORY_MAP.size} categories from API`);
19
+ }
20
+ catch (error) {
21
+ console.error("Warning: Failed to load filter options from API:", error);
22
+ }
23
+ }
24
+ /** Update the in-memory format/category maps (e.g. after list_filters refresh). */
25
+ export function updateFilterMaps(formats, categories) {
26
+ FORMAT_MAP = new Map(formats.map((f) => [f.name, f.id]));
27
+ CATEGORY_MAP = new Map(categories.map((c) => [c.name, c.id]));
28
+ }
29
+ export async function fetchGameplayFormats() {
30
+ const url = `${API_BASE}/gameplay-formats/?game_slug=disney-lorcana`;
31
+ const response = await fetch(url, {
32
+ headers: { Referer: "https://tcg.ravensburgerplay.com/" },
33
+ });
34
+ if (!response.ok)
35
+ throw new Error("Failed to fetch formats");
36
+ return response.json();
37
+ }
38
+ export async function fetchCategories() {
39
+ const url = `${API_BASE}/event-configuration-templates/?game_slug=disney-lorcana`;
40
+ const response = await fetch(url, {
41
+ headers: { Referer: "https://tcg.ravensburgerplay.com/" },
42
+ });
43
+ if (!response.ok)
44
+ throw new Error("Failed to fetch categories");
45
+ return response.json();
46
+ }
47
+ export async function fetchEvents(params) {
48
+ const url = new URL(`${API_BASE}/events/`);
49
+ for (const [key, value] of Object.entries(params)) {
50
+ if (Array.isArray(value)) {
51
+ value.forEach((v) => url.searchParams.append(key, v));
52
+ }
53
+ else if (value !== undefined && value !== "") {
54
+ url.searchParams.append(key, value);
55
+ }
56
+ }
57
+ const response = await fetch(url.toString(), {
58
+ method: "GET",
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ Referer: "https://tcg.ravensburgerplay.com/",
62
+ },
63
+ });
64
+ if (!response.ok) {
65
+ const text = await response.text();
66
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${text}`);
67
+ }
68
+ return response.json();
69
+ }
70
+ export async function fetchEventDetails(eventId) {
71
+ const url = `${API_BASE}/events/${eventId}/`;
72
+ const response = await fetch(url, {
73
+ method: "GET",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ Referer: "https://tcg.ravensburgerplay.com/",
77
+ },
78
+ });
79
+ if (!response.ok) {
80
+ const text = await response.text();
81
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${text}`);
82
+ }
83
+ return response.json();
84
+ }
85
+ export async function fetchEventRegistrations(eventId, page = 1, pageSize = 25) {
86
+ const url = new URL(`${API_BASE}/events/${eventId}/registrations/`);
87
+ url.searchParams.set("page", page.toString());
88
+ url.searchParams.set("page_size", pageSize.toString());
89
+ const response = await fetch(url.toString(), {
90
+ method: "GET",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Referer: "https://tcg.ravensburgerplay.com/",
94
+ },
95
+ });
96
+ if (!response.ok) {
97
+ const text = await response.text();
98
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${text}`);
99
+ }
100
+ return response.json();
101
+ }
102
+ export async function fetchTournamentRoundStandings(roundId, page = 1, pageSize = 25) {
103
+ const url = new URL(`${API_BASE}/tournament-rounds/${roundId}/standings/paginated/`);
104
+ url.searchParams.set("page", page.toString());
105
+ url.searchParams.set("page_size", pageSize.toString());
106
+ const response = await fetch(url.toString(), {
107
+ method: "GET",
108
+ headers: {
109
+ "Content-Type": "application/json",
110
+ Referer: "https://tcg.ravensburgerplay.com/",
111
+ },
112
+ });
113
+ if (!response.ok) {
114
+ const text = await response.text();
115
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${text}`);
116
+ }
117
+ return response.json();
118
+ }
119
+ export async function fetchStores(params) {
120
+ const url = new URL(`${API_BASE}/game-stores/`);
121
+ url.searchParams.append("game_id", "1");
122
+ for (const [key, value] of Object.entries(params)) {
123
+ if (value !== undefined && value !== "") {
124
+ url.searchParams.append(key, value);
125
+ }
126
+ }
127
+ const response = await fetch(url.toString(), {
128
+ method: "GET",
129
+ headers: {
130
+ "Content-Type": "application/json",
131
+ Referer: "https://tcg.ravensburgerplay.com/",
132
+ },
133
+ });
134
+ if (!response.ok) {
135
+ const text = await response.text();
136
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${text}`);
137
+ }
138
+ return response.json();
139
+ }
140
+ /** Resolve format display names to API IDs. */
141
+ export function resolveFormatIds(formatNames) {
142
+ const ids = [];
143
+ for (const name of formatNames) {
144
+ const id = FORMAT_MAP.get(name);
145
+ if (id)
146
+ ids.push(id);
147
+ else
148
+ console.error(`Warning: Unknown format "${name}"`);
149
+ }
150
+ return ids;
151
+ }
152
+ /** Resolve category display names to API IDs. */
153
+ export function resolveCategoryIds(categoryNames) {
154
+ const ids = [];
155
+ for (const name of categoryNames) {
156
+ const id = CATEGORY_MAP.get(name);
157
+ if (id)
158
+ ids.push(id);
159
+ else
160
+ console.error(`Warning: Unknown category "${name}"`);
161
+ }
162
+ return ids;
163
+ }
164
+ /** Reverse lookup: category template ID to display name. */
165
+ export function getCategoryName(templateId) {
166
+ for (const [name, id] of CATEGORY_MAP.entries()) {
167
+ if (id === templateId)
168
+ return name;
169
+ }
170
+ return templateId;
171
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Human-readable formatters for events, stores, standings, and registrations.
3
+ */
4
+ import type { Event, GameStore, RegistrationEntry, StandingEntry } from "./types.js";
5
+ export declare function formatStore(gameStore: GameStore): string;
6
+ export declare function formatStandingEntry(entry: StandingEntry, index: number): string;
7
+ export declare function formatRegistrationEntry(entry: RegistrationEntry, index: number): string;
8
+ export declare function formatEvent(event: Event): string;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Human-readable formatters for events, stores, standings, and registrations.
3
+ */
4
+ import { getCategoryName } from "./api.js";
5
+ export function formatStore(gameStore) {
6
+ const store = gameStore.store;
7
+ const lines = [`**${store.name}** (Store ID: ${store.id})`];
8
+ if (store.full_address) {
9
+ lines.push(`📍 ${store.full_address}`);
10
+ }
11
+ if (store.phone_number) {
12
+ lines.push(`📞 ${store.phone_number}`);
13
+ }
14
+ if (store.email) {
15
+ lines.push(`📧 ${store.email}`);
16
+ }
17
+ if (store.website) {
18
+ lines.push(`🌐 ${store.website}`);
19
+ }
20
+ if (gameStore.store_types_pretty && gameStore.store_types_pretty.length > 0) {
21
+ lines.push(`🏷️ Types: ${gameStore.store_types_pretty.join(", ")}`);
22
+ }
23
+ if (store.bio) {
24
+ lines.push(`\n${store.bio}`);
25
+ }
26
+ const socials = [];
27
+ if (store.discord_url)
28
+ socials.push(`Discord: ${store.discord_url}`);
29
+ if (store.facebook_url)
30
+ socials.push(`Facebook: ${store.facebook_url}`);
31
+ if (store.instagram_handle)
32
+ socials.push(`Instagram: @${store.instagram_handle}`);
33
+ if (store.twitter_handle)
34
+ socials.push(`Twitter: @${store.twitter_handle}`);
35
+ if (socials.length > 0) {
36
+ lines.push(`\n${socials.join(" | ")}`);
37
+ }
38
+ return lines.join("\n");
39
+ }
40
+ export function formatStandingEntry(entry, index) {
41
+ const rank = entry.rank ?? entry.placement ?? index + 1;
42
+ const name = entry.player_name ?? entry.display_name ?? entry.username ?? "—";
43
+ const lines = [`${rank}. ${name}`];
44
+ if (entry.wins !== undefined || entry.losses !== undefined) {
45
+ lines.push(` Record: ${entry.wins ?? 0}-${entry.losses ?? 0}`);
46
+ }
47
+ if (entry.match_points !== undefined) {
48
+ lines.push(` Match points: ${entry.match_points}`);
49
+ }
50
+ if (entry.opponent_match_win_pct !== undefined) {
51
+ lines.push(` OMWP: ${(Number(entry.opponent_match_win_pct) * 100).toFixed(1)}%`);
52
+ }
53
+ if (entry.game_win_pct !== undefined) {
54
+ lines.push(` GWP: ${(Number(entry.game_win_pct) * 100).toFixed(1)}%`);
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ export function formatRegistrationEntry(entry, index) {
59
+ const name = entry.display_name ??
60
+ entry.username ??
61
+ (entry.user &&
62
+ (entry.user.display_name ??
63
+ entry.user.username ??
64
+ [entry.user.first_name, entry.user.last_name].filter(Boolean).join(" "))) ??
65
+ "—";
66
+ const lines = [`${index + 1}. ${name}`];
67
+ if (entry.status) {
68
+ lines.push(` Status: ${entry.status}`);
69
+ }
70
+ if (entry.registered_at) {
71
+ try {
72
+ const d = new Date(entry.registered_at);
73
+ lines.push(` Registered: ${d.toLocaleString()}`);
74
+ }
75
+ catch {
76
+ lines.push(` Registered: ${entry.registered_at}`);
77
+ }
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+ export function formatEvent(event) {
82
+ const lines = [`**${event.name}** (ID: ${event.id})`];
83
+ if (event.start_datetime) {
84
+ const startDate = new Date(event.start_datetime);
85
+ lines.push(`📅 ${startDate.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} at ${startDate.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`);
86
+ }
87
+ if (event.gameplay_format?.name) {
88
+ lines.push(`🎮 Format: ${event.gameplay_format.name}`);
89
+ }
90
+ if (event.event_configuration_template) {
91
+ lines.push(`📁 Category: ${getCategoryName(event.event_configuration_template)}`);
92
+ }
93
+ if (event.store?.name) {
94
+ lines.push(`🏪 Store: ${event.store.name}`);
95
+ }
96
+ if (event.full_address) {
97
+ lines.push(`📍 ${event.full_address}`);
98
+ }
99
+ if (event.distance_in_miles != null) {
100
+ lines.push(`🚗 Distance: ${event.distance_in_miles.toFixed(1)} miles`);
101
+ }
102
+ if (event.cost_in_cents !== undefined) {
103
+ if (event.cost_in_cents > 0) {
104
+ const cost = event.cost_in_cents / 100;
105
+ lines.push(`💰 Entry: ${event.currency || "USD"} $${cost.toFixed(2)}`);
106
+ }
107
+ else {
108
+ lines.push(`💰 Entry: Free`);
109
+ }
110
+ }
111
+ if (event.capacity) {
112
+ const registered = event.registered_user_count || 0;
113
+ lines.push(`👥 Participants: ${registered}/${event.capacity}`);
114
+ }
115
+ else if (event.registered_user_count !== undefined) {
116
+ lines.push(`👥 Registered: ${event.registered_user_count}`);
117
+ }
118
+ if (event.display_status) {
119
+ lines.push(`📊 Status: ${event.display_status}`);
120
+ }
121
+ if (event.settings?.event_lifecycle_status) {
122
+ lines.push(`🎟️ Registration: ${event.settings.event_lifecycle_status.replace(/_/g, " ").toLowerCase()}`);
123
+ }
124
+ if (event.is_headlining_event) {
125
+ lines.push(`⭐ Featured Event`);
126
+ }
127
+ if (event.event_is_online) {
128
+ lines.push(`🌐 Online Event`);
129
+ }
130
+ if (event.description) {
131
+ lines.push(`\n${event.description}`);
132
+ }
133
+ return lines.join("\n");
134
+ }