From 33b866f8959df3f8e06f0347566693461c766a41 Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 8 Mar 2026 20:27:36 -0700 Subject: [PATCH 1/5] feat: reactive potion balances via WebSocket consumables channel Wire up full pipeline for real-time potion balance updates: - PG NOTIFY trigger on consumables table (indexer migration) - API: LISTEN consumables_update, broadcast on consumables channel - Client: useWebSocket consumables handler + GameDirector filtering by wallet Co-Authored-By: Claude Opus 4.6 --- api/AGENTS.md | 6 +- api/src/ws/subscriptions.test.ts | 49 ++++++ api/src/ws/subscriptions.ts | 19 ++- client/src/contexts/GameDirector.test.tsx | 70 ++++++++- client/src/contexts/GameDirector.tsx | 17 +- client/src/hooks/useWebSocket.test.ts | 147 ++++++++++++++++++ client/src/hooks/useWebSocket.ts | 22 ++- client/tsconfig.tsbuildinfo | 2 +- .../migrations/0004_consumables_trigger.sql | 28 ++++ 9 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 client/src/hooks/useWebSocket.test.ts create mode 100644 indexer/migrations/0004_consumables_trigger.sql diff --git a/api/AGENTS.md b/api/AGENTS.md index 2dda7d93..9fb8ebc5 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -39,8 +39,8 @@ 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"]}` 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 +55,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/ws/subscriptions.test.ts b/api/src/ws/subscriptions.test.ts index 448d1655..443d44d9 100644 --- a/api/src/ws/subscriptions.test.ts +++ b/api/src/ws/subscriptions.test.ts @@ -313,4 +313,53 @@ 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"]); + }); + }); }); diff --git a/api/src/ws/subscriptions.ts b/api/src/ws/subscriptions.ts index f41c7f80..6086f563 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,7 +19,7 @@ interface WebSocketLike { OPEN?: number; } -export type Channel = "summit" | "event"; +export type Channel = "summit" | "event" | "consumables"; interface ClientSubscription { ws: WebSocketLike; @@ -64,6 +65,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 +112,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,13 +165,16 @@ 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 }); let sentCount = 0; diff --git a/client/src/contexts/GameDirector.test.tsx b/client/src/contexts/GameDirector.test.tsx index 77220920..ffe62652 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: 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,65 @@ 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({}) : updater; + expect(result).toEqual({ + "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(); + }); +}); diff --git a/client/src/contexts/GameDirector.tsx b/client/src/contexts/GameDirector.tsx index ef12ddbb..1b2e557f 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 { ConsumablesData, EventData, SummitData } from "@/hooks/useWebSocket"; import { useWebSocket } from "@/hooks/useWebSocket"; import { useAutopilotStore } from "@/stores/autopilotStore"; import { useGameStore } from "@/stores/gameStore"; @@ -417,12 +417,25 @@ export const GameDirector = ({ children }: PropsWithChildren) => { } }; + const handleConsumables = (data: ConsumablesData) => { + if (!account?.address) return; + if (addAddressPadding(account.address) !== addAddressPadding(data.owner)) return; + + setTokenBalances(() => ({ + "EXTRA LIFE": data.xlife_count, + ATTACK: data.attack_count, + REVIVE: data.revive_count, + POISON: data.poison_count, + })); + }; + // WebSocket subscription useWebSocket({ url: currentNetworkConfig.wsUrl, - channels: ["summit", "event"], + channels: ["summit", "event", "consumables"], 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..a35b72a8 --- /dev/null +++ b/client/src/hooks/useWebSocket.test.ts @@ -0,0 +1,147 @@ +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"); + }); +}); diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index e22c04cc..48baab68 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,20 @@ 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 UseWebSocketOptions { url: string; channels: Channel[]; onSummit?: (data: SummitData) => void; onEvent?: (data: EventData) => void; + onConsumables?: (data: ConsumablesData) => void; onConnectionChange?: (state: ConnectionState) => void; enabled?: boolean; } @@ -80,6 +90,7 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { channels, onSummit, onEvent, + onConsumables, onConnectionChange, enabled = true, } = options; @@ -91,11 +102,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 +125,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; diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo index c9ae3037..c35c99bc 100644 --- a/client/tsconfig.tsbuildinfo +++ b/client/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/patchCoverage.imports.test.ts","./src/vite-env.d.ts","./src/api/ekubo.test.ts","./src/api/ekubo.ts","./src/api/starknet.test.ts","./src/api/starknet.ts","./src/api/summitApi.test.ts","./src/api/summitApi.ts","./src/components/ActionBar.test.tsx","./src/components/ActionBar.tsx","./src/components/AttackingBeasts.tsx","./src/components/BeastCard.tsx","./src/components/BeastCollection.tsx","./src/components/BeastProfile.tsx","./src/components/BurgerMenu.tsx","./src/components/ClaimRewardsButton.tsx","./src/components/Countdown.tsx","./src/components/DiplomacyPopover.tsx","./src/components/EventHistoryButton.tsx","./src/components/FinalShowdown.tsx","./src/components/GameNotificationFeed.tsx","./src/components/Icons.tsx","./src/components/KilledByAdventurers.tsx","./src/components/Leaderboard.jsx","./src/components/Leaderboard.test.tsx","./src/components/LeaderboardButton.tsx","./src/components/Migrating.tsx","./src/components/ProfileCard.tsx","./src/components/QuestBoard.tsx","./src/components/QuestRewardsRemainingBar.tsx","./src/components/RewardsRemainingBar.tsx","./src/components/Summit.tsx","./src/components/TermsOfServiceModal.tsx","./src/components/dialogs/AutopilotConfigModal.tsx","./src/components/dialogs/BeastDexModal.tsx","./src/components/dialogs/BeastUpgradeModal.tsx","./src/components/dialogs/ConnectWallet.tsx","./src/components/dialogs/DCATab.tsx","./src/components/dialogs/EventHistoryModal.tsx","./src/components/dialogs/LeaderboardModal.tsx","./src/components/dialogs/MarketplaceModal.tsx","./src/components/dialogs/QuestsModal.tsx","./src/components/dialogs/SummitGiftModal.tsx","./src/components/dialogs/TopUpStrkModal.tsx","./src/contexts/GameDirector.test.tsx","./src/contexts/GameDirector.tsx","./src/contexts/QuestGuide.tsx","./src/contexts/Statistics.test.tsx","./src/contexts/Statistics.tsx","./src/contexts/controller.test.tsx","./src/contexts/controller.tsx","./src/contexts/sound.tsx","./src/contexts/starknet.tsx","./src/dojo/useGameTokens.test.tsx","./src/dojo/useGameTokens.ts","./src/dojo/useSystemCalls.ts","./src/hooks/useWebSocket.ts","./src/pages/MainPage.tsx","./src/stores/autopilotStore.ts","./src/stores/gameStore.ts","./src/types/game.ts","./src/utils/BeastData.ts","./src/utils/addressNameCache.ts","./src/utils/analytics.ts","./src/utils/beasts.test.ts","./src/utils/beasts.ts","./src/utils/events.test.ts","./src/utils/events.ts","./src/utils/networkConfig.ts","./src/utils/styles.ts","./src/utils/summitRewards.ts","./src/utils/themes.ts","./src/utils/translation.test.ts","./src/utils/translation.ts","./src/utils/utils.test.ts","./src/utils/utils.ts","./src/utils/variants.test.ts","./src/utils/variants.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/patchCoverage.imports.test.ts","./src/vite-env.d.ts","./src/api/ekubo.test.ts","./src/api/ekubo.ts","./src/api/starknet.test.ts","./src/api/starknet.ts","./src/api/summitApi.test.ts","./src/api/summitApi.ts","./src/components/ActionBar.test.tsx","./src/components/ActionBar.tsx","./src/components/AttackingBeasts.tsx","./src/components/BeastCard.tsx","./src/components/BeastCollection.tsx","./src/components/BeastProfile.tsx","./src/components/BurgerMenu.tsx","./src/components/ClaimRewardsButton.tsx","./src/components/Countdown.tsx","./src/components/DiplomacyPopover.tsx","./src/components/EventHistoryButton.tsx","./src/components/FinalShowdown.tsx","./src/components/GameNotificationFeed.tsx","./src/components/Icons.tsx","./src/components/KilledByAdventurers.tsx","./src/components/Leaderboard.jsx","./src/components/Leaderboard.test.tsx","./src/components/LeaderboardButton.tsx","./src/components/Migrating.tsx","./src/components/ProfileCard.tsx","./src/components/QuestBoard.tsx","./src/components/QuestRewardsRemainingBar.tsx","./src/components/RewardsRemainingBar.tsx","./src/components/Summit.tsx","./src/components/TermsOfServiceModal.tsx","./src/components/dialogs/AutopilotConfigModal.tsx","./src/components/dialogs/BeastDexModal.tsx","./src/components/dialogs/BeastUpgradeModal.tsx","./src/components/dialogs/ConnectWallet.tsx","./src/components/dialogs/DCATab.tsx","./src/components/dialogs/EventHistoryModal.tsx","./src/components/dialogs/LeaderboardModal.tsx","./src/components/dialogs/MarketplaceModal.tsx","./src/components/dialogs/QuestsModal.tsx","./src/components/dialogs/SummitGiftModal.tsx","./src/components/dialogs/TopUpStrkModal.tsx","./src/contexts/GameDirector.test.tsx","./src/contexts/GameDirector.tsx","./src/contexts/QuestGuide.tsx","./src/contexts/Statistics.test.tsx","./src/contexts/Statistics.tsx","./src/contexts/controller.test.tsx","./src/contexts/controller.tsx","./src/contexts/sound.tsx","./src/contexts/starknet.tsx","./src/dojo/useGameTokens.test.tsx","./src/dojo/useGameTokens.ts","./src/dojo/useSystemCalls.ts","./src/hooks/useWebSocket.test.ts","./src/hooks/useWebSocket.ts","./src/pages/MainPage.tsx","./src/stores/autopilotStore.ts","./src/stores/gameStore.ts","./src/types/game.ts","./src/utils/BeastData.ts","./src/utils/addressNameCache.ts","./src/utils/analytics.ts","./src/utils/beasts.test.ts","./src/utils/beasts.ts","./src/utils/events.test.ts","./src/utils/events.ts","./src/utils/networkConfig.ts","./src/utils/styles.ts","./src/utils/summitRewards.ts","./src/utils/themes.ts","./src/utils/translation.test.ts","./src/utils/translation.ts","./src/utils/utils.test.ts","./src/utils/utils.ts","./src/utils/variants.test.ts","./src/utils/variants.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/indexer/migrations/0004_consumables_trigger.sql b/indexer/migrations/0004_consumables_trigger.sql new file mode 100644 index 00000000..62d75894 --- /dev/null +++ b/indexer/migrations/0004_consumables_trigger.sql @@ -0,0 +1,28 @@ +-- Migration: Add NOTIFY trigger for real-time consumables balance updates +-- +-- Channel: consumables_update +-- Triggered when consumables row is inserted or updated +-- Publishes owner address and all four potion counts + +CREATE OR REPLACE FUNCTION notify_consumables_update() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify( + 'consumables_update', + json_build_object( + 'owner', NEW.owner, + 'xlife_count', NEW.xlife_count, + 'attack_count', NEW.attack_count, + 'revive_count', NEW.revive_count, + 'poison_count', NEW.poison_count + )::text + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS consumables_update_trigger ON consumables; +CREATE TRIGGER consumables_update_trigger + AFTER INSERT OR UPDATE ON consumables + FOR EACH ROW + EXECUTE FUNCTION notify_consumables_update(); From f8dabb449264d8e66a51922fbe375ef93695cd81 Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 8 Mar 2026 20:30:02 -0700 Subject: [PATCH 2/5] fix: type useAccountMock return to satisfy strict tsc in Vercel build Co-Authored-By: Claude Opus 4.6 --- client/src/contexts/GameDirector.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/contexts/GameDirector.test.tsx b/client/src/contexts/GameDirector.test.tsx index ffe62652..c30f3506 100644 --- a/client/src/contexts/GameDirector.test.tsx +++ b/client/src/contexts/GameDirector.test.tsx @@ -4,7 +4,7 @@ import type { GameAction, selection } from "@/types/game"; const hoisted = vi.hoisted(() => ({ useWebSocketMock: vi.fn(), - useAccountMock: vi.fn(() => ({ account: undefined })), + useAccountMock: vi.fn((): { account: { address: string } | undefined } => ({ account: undefined })), getSummitDataMock: vi.fn(async () => null), getDiplomacyMock: vi.fn(async () => []), executeActionMock: vi.fn(async () => []), From ffede6c1f3ed3264acbe322d752e8e5237b9d819 Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 8 Mar 2026 20:34:55 -0700 Subject: [PATCH 3/5] fix: spread prev balances in consumables handler + add migration journal entry Address review feedback: - Merge consumables into existing tokenBalances instead of overwriting - Register 0004_consumables_trigger in Drizzle migration journal Co-Authored-By: Claude Opus 4.6 --- client/src/contexts/GameDirector.test.tsx | 4 +++- client/src/contexts/GameDirector.tsx | 3 ++- indexer/migrations/meta/_journal.json | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/contexts/GameDirector.test.tsx b/client/src/contexts/GameDirector.test.tsx index c30f3506..082fff65 100644 --- a/client/src/contexts/GameDirector.test.tsx +++ b/client/src/contexts/GameDirector.test.tsx @@ -201,8 +201,10 @@ describe("GameDirector consumables handler", () => { expect(hoisted.setTokenBalancesMock).toHaveBeenCalled(); const updater = hoisted.setTokenBalancesMock.mock.calls[0][0]; - const result = typeof updater === "function" ? updater({}) : updater; + 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, diff --git a/client/src/contexts/GameDirector.tsx b/client/src/contexts/GameDirector.tsx index 1b2e557f..bccdab2e 100644 --- a/client/src/contexts/GameDirector.tsx +++ b/client/src/contexts/GameDirector.tsx @@ -421,7 +421,8 @@ export const GameDirector = ({ children }: PropsWithChildren) => { if (!account?.address) return; if (addAddressPadding(account.address) !== addAddressPadding(data.owner)) return; - setTokenBalances(() => ({ + setTokenBalances((prev: Record) => ({ + ...prev, "EXTRA LIFE": data.xlife_count, ATTACK: data.attack_count, REVIVE: data.revive_count, diff --git a/indexer/migrations/meta/_journal.json b/indexer/migrations/meta/_journal.json index 50638ad7..ac620648 100644 --- a/indexer/migrations/meta/_journal.json +++ b/indexer/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1770990939403, "tag": "0003_consumables", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1770990939404, + "tag": "0004_consumables_trigger", + "breakpoints": true } ] } \ No newline at end of file From ae2fb1279e1525674e1401f1abc6b5feadb1e27f Mon Sep 17 00:00:00 2001 From: loothero Date: Mon, 9 Mar 2026 05:32:25 -0700 Subject: [PATCH 4/5] fix: include consumables in root endpoint WS channel list Co-Authored-By: Claude Opus 4.6 --- api/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/index.ts b/api/src/index.ts index 014be968..86ba9cec 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -685,8 +685,8 @@ 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"]}', }, }; From ef4e4a795ecec0f30efc2c8add9be143b19005b4 Mon Sep 17 00:00:00 2001 From: loothero Date: Mon, 9 Mar 2026 08:50:51 -0700 Subject: [PATCH 5/5] feat: add supply WS channel for real-time consumables-held-by-players Wire consumables_supply PG NOTIFY trigger (aggregate SUM per ERC20), API supply channel + LISTEN, client useWebSocket onSupply callback, and StatisticsProvider handler to replace polling for "Potions Held By Players" display. Per-ERC20 keyed payload for extensibility. Co-Authored-By: Claude Opus 4.6 --- api/AGENTS.md | 5 +- api/src/index.ts | 4 +- api/src/ws/subscriptions.test.ts | 40 ++++++++++- api/src/ws/subscriptions.ts | 14 +++- client/src/contexts/Statistics.test.tsx | 45 ++++++++++++- client/src/contexts/Statistics.tsx | 17 +++++ client/src/hooks/useWebSocket.test.ts | 66 +++++++++++++++++++ client/src/hooks/useWebSocket.ts | 17 +++-- .../migrations/0004_consumables_trigger.sql | 29 +++++++- 9 files changed, 221 insertions(+), 16 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index 9fb8ebc5..8e73914f 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -39,8 +39,9 @@ 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`, `consumables` - - subscribe payload: `{"type":"subscribe","channels":["summit","event","consumables"]}` + - channels: `summit`, `event`, `consumables`, `supply` + - subscribe payload: `{"type":"subscribe","channels":["summit","event","consumables","supply"]}` + - `supply` channel payload: `{"ATTACK": 236483, "REVIVE": 82604, "EXTRA LIFE": 15538, "POISON": 320004}` (per-ERC20 keyed) 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`. diff --git a/api/src/index.ts b/api/src/index.ts index 86ba9cec..e2d2a328 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -685,8 +685,8 @@ app.get("/", (c) => { }, websocket: { endpoint: "WS /ws", - channels: ["summit", "event", "consumables"], - subscribe: '{"type":"subscribe","channels":["summit","event","consumables"]}', + channels: ["summit", "event", "consumables", "supply"], + subscribe: '{"type":"subscribe","channels":["summit","event","consumables","supply"]}', }, }; diff --git a/api/src/ws/subscriptions.test.ts b/api/src/ws/subscriptions.test.ts index 443d44d9..99cfdf76 100644 --- a/api/src/ws/subscriptions.test.ts +++ b/api/src/ws/subscriptions.test.ts @@ -330,19 +330,19 @@ describe("SubscriptionHub", () => { expect(response.channels).toEqual(["consumables"]); }); - it("should subscribe to all three channels", () => { + it("should subscribe to all four channels", () => { const { ws, messages } = createMockWs(); hub.addClient("client-1", ws); hub.handleMessage( "client-1", - JSON.stringify({ type: "subscribe", channels: ["summit", "event", "consumables"] }) + JSON.stringify({ type: "subscribe", channels: ["summit", "event", "consumables", "supply"] }) ); expect(messages.length).toBe(1); const response = JSON.parse(messages[0]); expect(response.type).toBe("subscribed"); - expect(response.channels).toEqual(["summit", "event", "consumables"]); + expect(response.channels).toEqual(["summit", "event", "consumables", "supply"]); }); it("should unsubscribe from consumables channel", () => { @@ -362,4 +362,38 @@ describe("SubscriptionHub", () => { expect(response.channels).toEqual(["consumables"]); }); }); + + describe("supply channel", () => { + it("should subscribe a client to supply channel", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.handleMessage( + "client-1", + JSON.stringify({ type: "subscribe", channels: ["supply"] }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("subscribed"); + expect(response.channels).toEqual(["supply"]); + }); + + it("should unsubscribe from supply channel", () => { + const { ws, messages } = createMockWs(); + hub.addClient("client-1", ws); + + hub.subscribe("client-1", ["supply"]); + + hub.handleMessage( + "client-1", + JSON.stringify({ type: "unsubscribe", channels: ["supply"] }) + ); + + expect(messages.length).toBe(1); + const response = JSON.parse(messages[0]); + expect(response.type).toBe("unsubscribed"); + expect(response.channels).toEqual(["supply"]); + }); + }); }); diff --git a/api/src/ws/subscriptions.ts b/api/src/ws/subscriptions.ts index 6086f563..560e99c1 100644 --- a/api/src/ws/subscriptions.ts +++ b/api/src/ws/subscriptions.ts @@ -6,6 +6,7 @@ * - summit: Beast stats updates for summit beast * - event: Activity feed from summit_log * - consumables: Potion balance updates per owner + * - supply: Aggregate player-held supply per ERC20 token */ import { pool } from "../db/client.js"; @@ -19,7 +20,7 @@ interface WebSocketLike { OPEN?: number; } -export type Channel = "summit" | "event" | "consumables"; +export type Channel = "summit" | "event" | "consumables" | "supply"; interface ClientSubscription { ws: WebSocketLike; @@ -73,6 +74,9 @@ interface ConsumablesPayload { poison_count: number; } +/** Per-ERC20 token supply keyed by token name (e.g. "ATTACK", "REVIVE") */ +type SupplyPayload = Record; + export class SubscriptionHub { private clients: Map = new Map(); private pgClient: pg.PoolClient | null = null; @@ -113,8 +117,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"); + await this.pgClient.query("LISTEN consumables_supply"); - console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert, consumables_update"); + console.log("[SubscriptionHub] Listening on: summit_update, summit_log_insert, consumables_update, consumables_supply"); } catch (error) { console.error("[SubscriptionHub] Failed to connect:", error); this.reconnect(); @@ -168,13 +173,16 @@ export class SubscriptionHub { case "consumables_update": this.broadcast("consumables", payload as ConsumablesPayload); break; + case "consumables_supply": + this.broadcast("supply", payload as SupplyPayload); + break; } } catch (error) { console.error("[SubscriptionHub] Failed to parse notification:", error); } } - private broadcast(channel: Channel, data: SummitPayload | EventPayload | ConsumablesPayload): void { + private broadcast(channel: Channel, data: SummitPayload | EventPayload | ConsumablesPayload | SupplyPayload): void { const message = JSON.stringify({ type: channel, data }); let sentCount = 0; diff --git a/client/src/contexts/Statistics.test.tsx b/client/src/contexts/Statistics.test.tsx index 4645d4a8..bac2b05e 100644 --- a/client/src/contexts/Statistics.test.tsx +++ b/client/src/contexts/Statistics.test.tsx @@ -1,11 +1,14 @@ import { act, create } from "react-test-renderer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getSwapQuoteMock, getBeastCountsMock, getQuestRewardsTotalMock, mockNetworkConfig } = vi.hoisted(() => ({ +const { getSwapQuoteMock, getBeastCountsMock, getConsumablesSupplyMock, getQuestRewardsTotalMock, useWebSocketMock, mockNetworkConfig } = vi.hoisted(() => ({ getSwapQuoteMock: vi.fn(async () => ({ total: "-2000000", totalDisplay: -2e6 })), getBeastCountsMock: vi.fn(async () => ({ total: 12, alive: 5, dead: 7 })), + getConsumablesSupplyMock: vi.fn(async () => ({ xlife: 0, attack: 0, revive: 0, poison: 0 })), getQuestRewardsTotalMock: vi.fn(async () => 25), + useWebSocketMock: vi.fn(), mockNetworkConfig: { + wsUrl: "wss://test.invalid", tokens: { erc20: [ { name: "ATTACK", address: "0xattack" }, @@ -22,10 +25,15 @@ vi.mock("@/api/ekubo", () => ({ vi.mock("@/api/summitApi", () => ({ useSummitApi: () => ({ getBeastCounts: getBeastCountsMock, + getConsumablesSupply: getConsumablesSupplyMock, getQuestRewardsTotal: getQuestRewardsTotalMock, }), })); +vi.mock("@/hooks/useWebSocket", () => ({ + useWebSocket: useWebSocketMock, +})); + vi.mock("./starknet", () => ({ useDynamicConnector: () => ({ currentNetworkConfig: mockNetworkConfig, @@ -90,4 +98,39 @@ describe("StatisticsProvider", () => { expect(getSwapQuoteMock).toHaveBeenCalledTimes(2); }); + + it("subscribes to supply channel and updates consumablesSupply on message", async () => { + await renderProvider(); + await flushEffects(); + + // useWebSocket should have been called with supply channel + expect(useWebSocketMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://test.invalid", + channels: ["supply"], + onSupply: expect.any(Function), + }), + ); + + // Simulate a supply WS message via the captured onSupply callback + const callArgs = useWebSocketMock.mock.calls[0][0] as { + onSupply: (data: Record) => void; + }; + + await act(async () => { + callArgs.onSupply({ + "EXTRA LIFE": 100, + ATTACK: 200, + REVIVE: 300, + POISON: 400, + }); + }); + + expect(capturedStatistics.consumablesSupply).toEqual({ + xlife: 100, + attack: 200, + revive: 300, + poison: 400, + }); + }); }); diff --git a/client/src/contexts/Statistics.tsx b/client/src/contexts/Statistics.tsx index 027ced9f..d0d86525 100644 --- a/client/src/contexts/Statistics.tsx +++ b/client/src/contexts/Statistics.tsx @@ -1,6 +1,8 @@ import { getSwapQuote } from "@/api/ekubo"; import { useSummitApi } from "@/api/summitApi"; import { QUEST_REWARDS_TOTAL_AMOUNT } from "@/contexts/GameDirector"; +import { useWebSocket } from "@/hooks/useWebSocket"; +import type { SupplyData } from "@/hooks/useWebSocket"; import { NETWORKS } from "@/utils/networkConfig"; import type { PropsWithChildren @@ -100,6 +102,21 @@ export const StatisticsProvider = ({ children }: PropsWithChildren) => { } }; + const handleSupply = (data: SupplyData) => { + setConsumablesSupply({ + xlife: data["EXTRA LIFE"] ?? 0, + attack: data["ATTACK"] ?? 0, + revive: data["REVIVE"] ?? 0, + poison: data["POISON"] ?? 0, + }); + }; + + useWebSocket({ + url: currentNetworkConfig.wsUrl, + channels: ["supply"], + onSupply: handleSupply, + }); + useEffect(() => { fetchBeastCounts(); fetchConsumablesSupply(); diff --git a/client/src/hooks/useWebSocket.test.ts b/client/src/hooks/useWebSocket.test.ts index a35b72a8..a421d519 100644 --- a/client/src/hooks/useWebSocket.test.ts +++ b/client/src/hooks/useWebSocket.test.ts @@ -144,4 +144,70 @@ describe("useWebSocket", () => { const parsed = JSON.parse(subscribeMsg!); expect(parsed.channels).toContain("consumables"); }); + + it("should call onSupply when supply message received", () => { + const onSupply = vi.fn(); + + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["supply"], + onSupply, + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + const payload = { + ATTACK: 236483, + REVIVE: 82604, + "EXTRA LIFE": 15538, + POISON: 320004, + }; + + act(() => { + mockWsInstance.onmessage?.({ + data: JSON.stringify({ type: "supply", data: payload }), + }); + }); + + expect(onSupply).toHaveBeenCalledWith(payload); + }); + + it("should not call onSupply for consumables messages", () => { + const onSupply = vi.fn(); + + act(() => { + create( + createElement(HookHarness, { + options: { + url: "wss://test.invalid", + channels: ["supply", "consumables"], + onSupply, + }, + }) + ); + }); + + act(() => { + mockWsInstance.onopen?.(); + }); + + act(() => { + mockWsInstance.onmessage?.({ + data: JSON.stringify({ + type: "consumables", + data: { owner: "0x1", xlife_count: 1, attack_count: 2, revive_count: 3, poison_count: 4 }, + }), + }); + }); + + expect(onSupply).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index 48baab68..95cb39db 100644 --- a/client/src/hooks/useWebSocket.ts +++ b/client/src/hooks/useWebSocket.ts @@ -6,11 +6,12 @@ * - summit: Beast stats updates for summit beast * - event: Activity feed from summit_log * - consumables: Potion balance updates per owner + * - supply: Aggregate player-held supply per ERC20 token */ import { useCallback, useEffect, useRef, useState } from "react"; -export type Channel = "summit" | "event" | "consumables"; +export type Channel = "summit" | "event" | "consumables" | "supply"; export type ConnectionState = "connecting" | "connected" | "disconnected" | "reconnecting"; @@ -70,12 +71,16 @@ export interface ConsumablesData { poison_count: number; } +/** Per-ERC20 token supply keyed by token name (e.g. "ATTACK", "REVIVE") */ +export type SupplyData = Record; + export interface UseWebSocketOptions { url: string; channels: Channel[]; onSummit?: (data: SummitData) => void; onEvent?: (data: EventData) => void; onConsumables?: (data: ConsumablesData) => void; + onSupply?: (data: SupplyData) => void; onConnectionChange?: (state: ConnectionState) => void; enabled?: boolean; } @@ -91,6 +96,7 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { onSummit, onEvent, onConsumables, + onSupply, onConnectionChange, enabled = true, } = options; @@ -102,11 +108,11 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { const pingIntervalRef = useRef | null>(null); const mountedRef = useRef(true); - const callbacksRef = useRef({ onSummit, onEvent, onConsumables, onConnectionChange }); + const callbacksRef = useRef({ onSummit, onEvent, onConsumables, onSupply, onConnectionChange }); useEffect(() => { - callbacksRef.current = { onSummit, onEvent, onConsumables, onConnectionChange }; - }, [onSummit, onEvent, onConsumables, onConnectionChange]); + callbacksRef.current = { onSummit, onEvent, onConsumables, onSupply, onConnectionChange }; + }, [onSummit, onEvent, onConsumables, onSupply, onConnectionChange]); const updateConnectionState = useCallback((state: ConnectionState) => { if (!mountedRef.current) return; @@ -128,6 +134,9 @@ export function useWebSocket(options: UseWebSocketOptions): UseWebSocketReturn { case "consumables": callbacksRef.current.onConsumables?.(message.data); break; + case "supply": + callbacksRef.current.onSupply?.(message.data); + break; case "subscribed": console.log("[WebSocket] Subscribed to:", message.channels); break; diff --git a/indexer/migrations/0004_consumables_trigger.sql b/indexer/migrations/0004_consumables_trigger.sql index 62d75894..2139acef 100644 --- a/indexer/migrations/0004_consumables_trigger.sql +++ b/indexer/migrations/0004_consumables_trigger.sql @@ -1,12 +1,19 @@ --- Migration: Add NOTIFY trigger for real-time consumables balance updates +-- Migration: Add NOTIFY triggers for real-time consumables updates -- -- Channel: consumables_update -- Triggered when consumables row is inserted or updated -- Publishes owner address and all four potion counts +-- +-- Channel: consumables_supply +-- Triggered on same events, publishes aggregate player-held supply +-- per ERC20 token (keyed by token name for extensibility) CREATE OR REPLACE FUNCTION notify_consumables_update() RETURNS TRIGGER AS $$ +DECLARE + supply RECORD; BEGIN + -- Per-owner balance update PERFORM pg_notify( 'consumables_update', json_build_object( @@ -17,6 +24,26 @@ BEGIN 'poison_count', NEW.poison_count )::text ); + + -- Aggregate supply across all players (per ERC20 token) + SELECT + coalesce(sum(xlife_count), 0) AS xlife, + coalesce(sum(attack_count), 0) AS attack, + coalesce(sum(revive_count), 0) AS revive, + coalesce(sum(poison_count), 0) AS poison + INTO supply + FROM consumables; + + PERFORM pg_notify( + 'consumables_supply', + json_build_object( + 'EXTRA LIFE', supply.xlife, + 'ATTACK', supply.attack, + 'REVIVE', supply.revive, + 'POISON', supply.poison + )::text + ); + RETURN NEW; END; $$ LANGUAGE plpgsql;