From 4c8fa1ed5b6a60824eb419843cc7651498084530 Mon Sep 17 00:00:00 2001 From: loothero Date: Mon, 9 Mar 2026 05:46:45 -0700 Subject: [PATCH] feat: add optional server-side owner filtering for WS channels Add consumables channel support and optional per-subscription filters to the WebSocket subscription hub. Clients can now subscribe with an owner filter for summit and consumables channels to receive only updates relevant to their wallet address. Co-Authored-By: Claude Opus 4.6 --- api/AGENTS.md | 8 +- api/src/index.ts | 5 +- api/src/ws/subscriptions.test.ts | 300 ++++++++++++++++++++++ api/src/ws/subscriptions.ts | 56 +++- client/src/contexts/GameDirector.test.tsx | 107 +++++++- client/src/contexts/GameDirector.tsx | 28 +- client/src/hooks/useWebSocket.test.ts | 206 +++++++++++++++ client/src/hooks/useWebSocket.ts | 36 ++- 8 files changed, 722 insertions(+), 24 deletions(-) create mode 100644 client/src/hooks/useWebSocket.test.ts diff --git a/api/AGENTS.md b/api/AGENTS.md index 2dda7d93..a4f2177c 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -39,8 +39,10 @@ Read [`../AGENTS.md`](../AGENTS.md) first for shared addresses/mechanics and ind - Root discovery route: `/` - WebSocket endpoint: `/ws` - message types: `subscribe`, `unsubscribe`, `ping` - - channels: `summit`, `event` - - subscribe payload: `{"type":"subscribe","channels":["summit","event"]}` + - channels: `summit`, `event`, `consumables` + - subscribe payload: `{"type":"subscribe","channels":["summit","event","consumables"]}` + - subscribe with filters: `{"type":"subscribe","channels":["summit","consumables"],"filters":{"summit":{"owner":"0x..."},"consumables":{"owner":"0x..."}}}` + - Supported filters: `summit` and `consumables` channels support `owner` filter. `event` channel does not support filtering. Query/pagination rules agents usually need: - `/beasts/all`: `limit` default `25`, max `100`; `offset`; filters `prefix`, `suffix`, `beast_id`, `name`, `owner`; `sort` in `summit_held_seconds|level`. @@ -55,7 +57,7 @@ Behavior details that affect integration: - `/` includes debug endpoint hints in development mode (`NODE_ENV != production`), but handlers are not implemented in this service file. ## Real-Time Pattern -`Indexer writes -> PostgreSQL NOTIFY (summit_update, summit_log_insert) -> SubscriptionHub LISTEN -> WS broadcast` +`Indexer writes -> PostgreSQL NOTIFY (summit_update, summit_log_insert, consumables_update) -> SubscriptionHub LISTEN -> WS broadcast` ## Middleware and Runtime Patterns - Middleware in `src/index.ts`: logger, compress, CORS. diff --git a/api/src/index.ts b/api/src/index.ts index 014be968..dd8a3654 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -685,8 +685,9 @@ app.get("/", (c) => { }, websocket: { endpoint: "WS /ws", - channels: ["summit", "event"], - subscribe: '{"type":"subscribe","channels":["summit","event"]}', + channels: ["summit", "event", "consumables"], + subscribe: '{"type":"subscribe","channels":["summit","event","consumables"]}', + subscribe_with_filters: '{"type":"subscribe","channels":["summit","consumables"],"filters":{"summit":{"owner":"0x..."},"consumables":{"owner":"0x..."}}}', }, }; diff --git a/api/src/ws/subscriptions.test.ts b/api/src/ws/subscriptions.test.ts index 448d1655..5a497cf2 100644 --- a/api/src/ws/subscriptions.test.ts +++ b/api/src/ws/subscriptions.test.ts @@ -313,4 +313,304 @@ describe("SubscriptionHub", () => { expect(hub.getStatus().clientCount).toBe(1); }); }); + + describe("consumables channel", () => { + it("should subscribe a client to consumables channel", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.handleMessage( + "client-1", + JSON.stringify({ type: "subscribe", channels: ["consumables"] }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("subscribed"); + expect(response.channels).toEqual(["consumables"]); + }); + + it("should subscribe to all three channels", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.handleMessage( + "client-1", + JSON.stringify({ type: "subscribe", channels: ["summit", "event", "consumables"] }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("subscribed"); + expect(response.channels).toEqual(["summit", "event", "consumables"]); + }); + + it("should unsubscribe from consumables channel", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.subscribe("client-1", ["consumables"]); + + hub.handleMessage( + "client-1", + JSON.stringify({ type: "unsubscribe", channels: ["consumables"] }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("unsubscribed"); + expect(response.channels).toEqual(["consumables"]); + }); + }); + + describe("channel filters", () => { + it("should store filters when subscribing with owner filter", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["consumables"], + filters: { consumables: { owner: "0x123" } }, + }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("subscribed"); + expect(response.filters).toEqual({ consumables: { owner: "0x123" } }); + }); + + it("should broadcast consumables only to matching owner filter", () => { + const { ws: ws1, messages: msgs1 } = createMockWs(); + const { ws: ws2, messages: msgs2 } = createMockWs(); + hub.addClient("client-1", ws1); + hub.addClient("client-2", ws2); + + // Client 1: subscribe with owner filter + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["consumables"], + filters: { consumables: { owner: "0xaaa" } }, + }) + ); + // Client 2: subscribe without filter + hub.handleMessage( + "client-2", + JSON.stringify({ type: "subscribe", channels: ["consumables"] }) + ); + + // Clear subscription confirmations + msgs1.length = 0; + msgs2.length = 0; + + // Simulate consumables_update notification + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "consumables_update", + payload: JSON.stringify({ + owner: "0xaaa", + xlife_count: 5, + attack_count: 3, + revive_count: 1, + poison_count: 2, + }), + }); + + // Filtered client receives (owner matches) + expect(msgs1.length).toBe(1); + const data1 = JSON.parse(msgs1[0]); + expect(data1.type).toBe("consumables"); + expect(data1.data.owner).toBe("0xaaa"); + + // Unfiltered client also receives (no filter = get everything) + expect(msgs2.length).toBe(1); + }); + + it("should NOT broadcast consumables to non-matching owner filter", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["consumables"], + filters: { consumables: { owner: "0xaaa" } }, + }) + ); + messages.length = 0; + + // Broadcast consumables with different owner + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "consumables_update", + payload: JSON.stringify({ + owner: "0xbbb", + xlife_count: 1, + attack_count: 1, + revive_count: 1, + poison_count: 1, + }), + }); + + expect(messages.length).toBe(0); + }); + + it("should broadcast summit only to matching owner filter", () => { + const { ws: ws1, messages: msgs1 } = createMockWs(); + const { ws: ws2, messages: msgs2 } = createMockWs(); + hub.addClient("client-1", ws1); + hub.addClient("client-2", ws2); + + // Client 1: subscribe with owner filter + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["summit"], + filters: { summit: { owner: "0xaaa" } }, + }) + ); + // Client 2: subscribe with different owner filter + hub.handleMessage( + "client-2", + JSON.stringify({ + type: "subscribe", + channels: ["summit"], + filters: { summit: { owner: "0xbbb" } }, + }) + ); + + msgs1.length = 0; + msgs2.length = 0; + + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "summit_update", + payload: JSON.stringify({ + token_id: 1, + current_health: 100, + owner: "0xaaa", + }), + }); + + // Client 1 matches owner + expect(msgs1.length).toBe(1); + // Client 2 does not match + expect(msgs2.length).toBe(0); + }); + + it("should always broadcast event channel regardless of filters", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + // Subscribe to event with an owner filter (should be ignored for event channel) + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["event"], + filters: { event: { owner: "0xaaa" } }, + }) + ); + messages.length = 0; + + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "summit_log_insert", + payload: JSON.stringify({ + id: "evt-1", + block_number: "100", + event_index: 0, + category: "Battle", + sub_category: "BattleEvent", + data: {}, + player: "0xbbb", + token_id: 1, + transaction_hash: "0x123", + created_at: "2026-01-01T00:00:00Z", + }), + }); + + // Event is always sent regardless of filter + expect(messages.length).toBe(1); + const data = JSON.parse(messages[0]); + expect(data.type).toBe("event"); + }); + + it("should clear filters on unsubscribe", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + // Subscribe with filter + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["consumables"], + filters: { consumables: { owner: "0xaaa" } }, + }) + ); + messages.length = 0; + + // Unsubscribe + hub.handleMessage( + "client-1", + JSON.stringify({ type: "unsubscribe", channels: ["consumables"] }) + ); + messages.length = 0; + + // Resubscribe without filter + hub.handleMessage( + "client-1", + JSON.stringify({ type: "subscribe", channels: ["consumables"] }) + ); + messages.length = 0; + + // Should receive all consumables (no filter active) + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "consumables_update", + payload: JSON.stringify({ + owner: "0xbbb", + xlife_count: 1, + attack_count: 1, + revive_count: 1, + poison_count: 1, + }), + }); + + expect(messages.length).toBe(1); + }); + + it("should normalize owner filter to lowercase", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + // Subscribe with mixed-case owner + hub.handleMessage( + "client-1", + JSON.stringify({ + type: "subscribe", + channels: ["consumables"], + filters: { consumables: { owner: "0xAAA" } }, + }) + ); + messages.length = 0; + + // Broadcast with lowercase owner + (hub as unknown as { handleNotification(msg: { channel: string; payload: string }): void }).handleNotification({ + channel: "consumables_update", + payload: JSON.stringify({ + owner: "0xaaa", + xlife_count: 1, + attack_count: 1, + revive_count: 1, + poison_count: 1, + }), + }); + + expect(messages.length).toBe(1); + }); + }); }); diff --git a/api/src/ws/subscriptions.ts b/api/src/ws/subscriptions.ts index f41c7f80..d336ede9 100644 --- a/api/src/ws/subscriptions.ts +++ b/api/src/ws/subscriptions.ts @@ -5,6 +5,7 @@ * Channels: * - summit: Beast stats updates for summit beast * - event: Activity feed from summit_log + * - consumables: Potion balance updates per owner */ import { pool } from "../db/client.js"; @@ -18,11 +19,16 @@ interface WebSocketLike { OPEN?: number; } -export type Channel = "summit" | "event"; +export type Channel = "summit" | "event" | "consumables"; + +export interface ChannelFilter { + owner?: string; // normalized lowercase address +} interface ClientSubscription { ws: WebSocketLike; channels: Set; + filters: Map; } interface SummitPayload { @@ -49,6 +55,8 @@ interface SummitPayload { block_number: string; transaction_hash: string; created_at: string; + // Beast data from trigger join + owner: string | null; } interface EventPayload { @@ -64,6 +72,14 @@ interface EventPayload { created_at: string; } +interface ConsumablesPayload { + owner: string; + xlife_count: number; + attack_count: number; + revive_count: number; + poison_count: number; +} + export class SubscriptionHub { private clients: Map = new Map(); private pgClient: pg.PoolClient | null = null; @@ -103,8 +119,9 @@ export class SubscriptionHub { await this.pgClient.query("LISTEN summit_update"); await this.pgClient.query("LISTEN summit_log_insert"); + await this.pgClient.query("LISTEN consumables_update"); - console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert"); + console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert, consumables_update"); } catch (error) { console.error("[SubscriptionHub] Failed to connect:", error); this.reconnect(); @@ -155,20 +172,35 @@ export class SubscriptionHub { case "summit_log_insert": this.broadcast("event", payload as EventPayload); break; + case "consumables_update": + this.broadcast("consumables", payload as ConsumablesPayload); + break; } } catch (error) { console.error("[SubscriptionHub] Failed to parse notification:", error); } } - private broadcast(channel: Channel, data: SummitPayload | EventPayload): void { + private broadcast(channel: Channel, data: SummitPayload | EventPayload | ConsumablesPayload): void { const message = JSON.stringify({ type: channel, data }); + // Extract owner from payload for filterable channels + const payloadOwner = (channel === "summit" || channel === "consumables") + ? ((data as { owner?: string }).owner?.toLowerCase() ?? null) + : null; + let sentCount = 0; const deadClients: string[] = []; for (const [id, client] of this.clients) { if (!client.channels.has(channel)) continue; + + // Apply owner filter for summit and consumables channels + const filter = client.filters.get(channel); + if (filter?.owner && payloadOwner !== null && filter.owner !== payloadOwner) { + continue; + } + if (this.send(id, client.ws, message)) { sentCount++; } else { @@ -235,7 +267,7 @@ export class SubscriptionHub { } addClient(id: string, ws: WebSocketLike): void { - this.clients.set(id, { ws, channels: new Set() }); + this.clients.set(id, { ws, channels: new Set(), filters: new Map() }); console.log(`[SubscriptionHub] Client connected: ${id} (total: ${this.clients.size})`); } @@ -244,12 +276,20 @@ export class SubscriptionHub { console.log(`[SubscriptionHub] Client disconnected: ${id} (total: ${this.clients.size})`); } - subscribe(id: string, channels: Channel[]): void { + subscribe(id: string, channels: Channel[], filters?: Record): void { const client = this.clients.get(id); if (!client) return; for (const channel of channels) { client.channels.add(channel); + + if (filters && filters[channel]) { + const filter = { ...filters[channel] }; + if (filter.owner) { + filter.owner = filter.owner.toLowerCase(); + } + client.filters.set(channel, filter); + } } console.log(`[SubscriptionHub] Client ${id} subscribed to: ${channels.join(", ")}`); @@ -261,6 +301,7 @@ export class SubscriptionHub { for (const channel of channels) { client.channels.delete(channel); + client.filters.delete(channel); } console.log(`[SubscriptionHub] Client ${id} unsubscribed from: ${channels.join(", ")}`); @@ -275,8 +316,9 @@ export class SubscriptionHub { switch (data.type) { case "subscribe": { const subChannels = data.channels || []; - this.subscribe(id, subChannels); - if (!this.send(id, client.ws, JSON.stringify({ type: "subscribed", channels: subChannels }))) { + const filters = data.filters || {}; + this.subscribe(id, subChannels, filters); + if (!this.send(id, client.ws, JSON.stringify({ type: "subscribed", channels: subChannels, filters }))) { this.removeDeadClient(id); } break; diff --git a/client/src/contexts/GameDirector.test.tsx b/client/src/contexts/GameDirector.test.tsx index 77220920..bd38795a 100644 --- a/client/src/contexts/GameDirector.test.tsx +++ b/client/src/contexts/GameDirector.test.tsx @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GameAction, selection } from "@/types/game"; const hoisted = vi.hoisted(() => ({ + useWebSocketMock: vi.fn(), + useAccountMock: vi.fn((): { account: { address: string } | undefined } => ({ account: undefined })), getSummitDataMock: vi.fn(async () => null), getDiplomacyMock: vi.fn(async () => []), executeActionMock: vi.fn(async () => []), @@ -35,9 +37,7 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("@starknet-react/core", () => ({ - useAccount: () => ({ - account: undefined, - }), + useAccount: hoisted.useAccountMock, })); vi.mock("./starknet", () => ({ @@ -49,7 +49,7 @@ vi.mock("./starknet", () => ({ })); vi.mock("@/hooks/useWebSocket", () => ({ - useWebSocket: vi.fn(), + useWebSocket: hoisted.useWebSocketMock, })); vi.mock("@/api/starknet", () => ({ @@ -170,3 +170,102 @@ describe("GameDirector executeGameAction", () => { expect(capturedDirector.pauseUpdates).toBe(false); }); }); + +describe("GameDirector consumables handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.getSummitDataMock.mockResolvedValue(null); + }); + + it("should update token balances when consumables update matches connected wallet", async () => { + hoisted.useAccountMock.mockReturnValue({ + account: { address: "0x123" }, + }); + + await renderProvider(); + + // Capture the onConsumables callback from useWebSocket call + const wsCall = hoisted.useWebSocketMock.mock.calls[0][0]; + expect(wsCall.channels).toContain("consumables"); + expect(wsCall.onConsumables).toBeDefined(); + + act(() => { + wsCall.onConsumables({ + owner: "0x123", + xlife_count: 5, + attack_count: 3, + revive_count: 1, + poison_count: 2, + }); + }); + + expect(hoisted.setTokenBalancesMock).toHaveBeenCalled(); + const updater = hoisted.setTokenBalancesMock.mock.calls[0][0]; + const result = typeof updater === "function" ? updater({ SKULL: 10, CORPSE: 5 }) : updater; + expect(result).toEqual({ + SKULL: 10, + CORPSE: 5, + "EXTRA LIFE": 5, + ATTACK: 3, + REVIVE: 1, + POISON: 2, + }); + }); + + it("should ignore consumables update for different wallet", async () => { + hoisted.useAccountMock.mockReturnValue({ + account: { address: "0x456" }, + }); + + await renderProvider(); + + const wsCall = hoisted.useWebSocketMock.mock.calls[0][0]; + + act(() => { + wsCall.onConsumables({ + owner: "0x999", + xlife_count: 10, + attack_count: 10, + revive_count: 10, + poison_count: 10, + }); + }); + + expect(hoisted.setTokenBalancesMock).not.toHaveBeenCalled(); + }); +}); + +describe("GameDirector WebSocket filters", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.getSummitDataMock.mockResolvedValue(null); + }); + + it("should pass owner filter to useWebSocket when wallet is connected", async () => { + hoisted.useAccountMock.mockReturnValue({ + account: { address: "0x0abc" }, + }); + + await renderProvider(); + + const wsCall = hoisted.useWebSocketMock.mock.calls[0][0]; + expect(wsCall.filters).toBeDefined(); + expect(wsCall.filters.summit).toBeDefined(); + expect(wsCall.filters.summit.owner).toBeTruthy(); + expect(wsCall.filters.consumables).toBeDefined(); + expect(wsCall.filters.consumables.owner).toBeTruthy(); + // event should not have a filter + expect(wsCall.filters.event).toBeUndefined(); + }); + + it("should not pass filters when wallet is not connected", async () => { + hoisted.useAccountMock.mockReturnValue({ + account: undefined, + }); + + await renderProvider(); + + const wsCall = hoisted.useWebSocketMock.mock.calls[0][0]; + expect(wsCall.filters).toBeUndefined(); + }); +}); diff --git a/client/src/contexts/GameDirector.tsx b/client/src/contexts/GameDirector.tsx index ef12ddbb..5c0c0ade 100644 --- a/client/src/contexts/GameDirector.tsx +++ b/client/src/contexts/GameDirector.tsx @@ -3,7 +3,7 @@ import { useSummitApi } from "@/api/summitApi"; import { useSound } from "@/contexts/sound"; import { useSystemCalls } from "@/dojo/useSystemCalls"; import type { TranslatedGameEvent } from "@/dojo/useSystemCalls"; -import type { EventData, SummitData } from "@/hooks/useWebSocket"; +import type { ChannelFilter, ConsumablesData, EventData, SummitData } from "@/hooks/useWebSocket"; import { useWebSocket } from "@/hooks/useWebSocket"; import { useAutopilotStore } from "@/stores/autopilotStore"; import { useGameStore } from "@/stores/gameStore"; @@ -417,12 +417,36 @@ export const GameDirector = ({ children }: PropsWithChildren) => { } }; + const handleConsumables = (data: ConsumablesData) => { + if (!account?.address) return; + // Server-side filtering is the primary filter; this client-side check is a safety fallback + if (addAddressPadding(account.address) !== addAddressPadding(data.owner)) return; + + setTokenBalances((prev: Record) => ({ + ...prev, + "EXTRA LIFE": data.xlife_count, + ATTACK: data.attack_count, + REVIVE: data.revive_count, + POISON: data.poison_count, + })); + }; + + const wsFilters: Partial> | undefined = + account?.address + ? { + summit: { owner: addAddressPadding(account.address) }, + consumables: { owner: addAddressPadding(account.address) }, + } + : undefined; + // WebSocket subscription useWebSocket({ url: currentNetworkConfig.wsUrl, - channels: ["summit", "event"], + channels: ["summit", "event", "consumables"], + filters: wsFilters, onSummit: handleSummit, onEvent: handleEvent, + onConsumables: handleConsumables, onConnectionChange: (state) => { console.log("[GameDirector] WebSocket connection state:", state); }, diff --git a/client/src/hooks/useWebSocket.test.ts b/client/src/hooks/useWebSocket.test.ts new file mode 100644 index 00000000..45ee9183 --- /dev/null +++ b/client/src/hooks/useWebSocket.test.ts @@ -0,0 +1,206 @@ +import { act, create } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; + +import { useWebSocket } from "./useWebSocket"; +import type { UseWebSocketOptions } from "./useWebSocket"; + +// Controllable mock WebSocket +class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + + readyState = MockWebSocket.OPEN; + OPEN = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((error: unknown) => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + sent: string[] = []; + + send(data: string) { + this.sent.push(data); + } + close() { + this.readyState = MockWebSocket.CLOSED; + } +} + +let mockWsInstance: MockWebSocket; + +function setMockInstance(instance: MockWebSocket) { + mockWsInstance = instance; +} + +vi.stubGlobal( + "WebSocket", + class extends MockWebSocket { + constructor() { + super(); + setMockInstance(this); + } + } +); + +function HookHarness(props: { options: UseWebSocketOptions }) { + useWebSocket(props.options); + return null; +} + +describe("useWebSocket", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call onConsumables when consumables message received", () => { + const onConsumables = vi.fn(); + + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["consumables"], + onConsumables, + }, + }) + ); + }); + + // Trigger open + message + act(() => { + mockWsInstance.onopen?.(); + }); + + const payload = { + owner: "0x123", + xlife_count: 5, + attack_count: 3, + revive_count: 1, + poison_count: 2, + }; + + act(() => { + mockWsInstance.onmessage?.({ + data: JSON.stringify({ type: "consumables", data: payload }), + }); + }); + + expect(onConsumables).toHaveBeenCalledWith(payload); + }); + + it("should not call onConsumables for summit messages", () => { + const onConsumables = vi.fn(); + + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["summit", "consumables"], + onConsumables, + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + act(() => { + mockWsInstance.onmessage?.({ + data: JSON.stringify({ type: "summit", data: { token_id: 1 } }), + }); + }); + + expect(onConsumables).not.toHaveBeenCalled(); + }); + + it("should include consumables in subscribe message", () => { + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["summit", "consumables"], + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + const subscribeMsg = mockWsInstance.sent.find((msg) => { + const parsed = JSON.parse(msg); + return parsed.type === "subscribe"; + }); + + expect(subscribeMsg).toBeDefined(); + const parsed = JSON.parse(subscribeMsg!); + expect(parsed.channels).toContain("consumables"); + }); + + it("should include filters in subscribe message when provided", () => { + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["summit", "consumables"], + filters: { + summit: { owner: "0xabc" }, + consumables: { owner: "0xabc" }, + }, + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + const subscribeMsg = mockWsInstance.sent.find((msg) => { + const parsed = JSON.parse(msg); + return parsed.type === "subscribe"; + }); + + expect(subscribeMsg).toBeDefined(); + const parsed = JSON.parse(subscribeMsg!); + expect(parsed.filters).toEqual({ + summit: { owner: "0xabc" }, + consumables: { owner: "0xabc" }, + }); + }); + + it("should not include filters key when no filters provided", () => { + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["summit", "event"], + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + const subscribeMsg = mockWsInstance.sent.find((msg) => { + const parsed = JSON.parse(msg); + return parsed.type === "subscribe"; + }); + + expect(subscribeMsg).toBeDefined(); + const parsed = JSON.parse(subscribeMsg!); + expect(parsed.filters).toBeUndefined(); + }); +}); diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index e22c04cc..4bede0dc 100644 --- a/client/src/hooks/useWebSocket.ts +++ b/client/src/hooks/useWebSocket.ts @@ -5,11 +5,12 @@ * Channels: * - summit: Beast stats updates for summit beast * - event: Activity feed from summit_log + * - consumables: Potion balance updates per owner */ import { useCallback, useEffect, useRef, useState } from "react"; -export type Channel = "summit" | "event"; +export type Channel = "summit" | "event" | "consumables"; export type ConnectionState = "connecting" | "connected" | "disconnected" | "reconnecting"; @@ -61,11 +62,25 @@ export interface EventData { created_at: string; } +export interface ConsumablesData { + owner: string; + xlife_count: number; + attack_count: number; + revive_count: number; + poison_count: number; +} + +export interface ChannelFilter { + owner?: string; +} + export interface UseWebSocketOptions { url: string; channels: Channel[]; + filters?: Partial>; onSummit?: (data: SummitData) => void; onEvent?: (data: EventData) => void; + onConsumables?: (data: ConsumablesData) => void; onConnectionChange?: (state: ConnectionState) => void; enabled?: boolean; } @@ -78,8 +93,10 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { const { url, channels, + filters, onSummit, onEvent, + onConsumables, onConnectionChange, enabled = true, } = options; @@ -91,11 +108,11 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { const pingIntervalRef = useRef | null>(null); const mountedRef = useRef(true); - const callbacksRef = useRef({ onSummit, onEvent, onConnectionChange }); + const callbacksRef = useRef({ onSummit, onEvent, onConsumables, onConnectionChange }); useEffect(() => { - callbacksRef.current = { onSummit, onEvent, onConnectionChange }; - }, [onSummit, onEvent, onConnectionChange]); + callbacksRef.current = { onSummit, onEvent, onConsumables, onConnectionChange }; + }, [onSummit, onEvent, onConsumables, onConnectionChange]); const updateConnectionState = useCallback((state: ConnectionState) => { if (!mountedRef.current) return; @@ -114,6 +131,9 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { case "event": callbacksRef.current.onEvent?.(message.data); break; + case "consumables": + callbacksRef.current.onConsumables?.(message.data); + break; case "subscribed": console.log("[WebSocket] Subscribed to:", message.channels); break; @@ -147,7 +167,11 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { updateConnectionState("connected"); if (channels.length > 0) { - ws.send(JSON.stringify({ type: "subscribe", channels })); + const subscribeMsg: Record = { type: "subscribe", channels }; + if (filters && Object.keys(filters).length > 0) { + subscribeMsg.filters = filters; + } + ws.send(JSON.stringify(subscribeMsg)); } if (pingIntervalRef.current) { @@ -195,7 +219,7 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { console.error("[WebSocket] Failed to connect:", error); updateConnectionState("disconnected"); } - }, [url, enabled, channels, handleMessage, updateConnectionState]); + }, [url, enabled, channels, filters, handleMessage, updateConnectionState]); const disconnect = useCallback(() => { if (reconnectTimeoutRef.current) {